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

⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8 llimphi-compositor/src/lib.rs Rust · 1478 líneas · 73.3 KB Historia Quién tocó qué crudo

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

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
17use std::collections::HashMap;
18use std::sync::Arc;
19
20use llimphi_layout::taffy::NodeId;
21use llimphi_layout::{ComputedLayout, LayoutTree, Style};
22use vello::kurbo::{
23    Affine, Ellipse, Point, Rect as KurboRect, RoundedRect, RoundedRectRadii, Stroke,
24};
25use vello::peniko::{BlendMode, Color, Fill, Gradient, ImageBrush as Image, Mix};
26
27mod anim;
28mod hero;
29mod layout_builder;
30mod render;
31mod ripple;
32mod semantics;
33mod view;
34pub use anim::{
35    ease_out_cubic, reconcile_size_anim, Anim, AnimRegistry, SizeAnim, SizeAnimRegistry,
36    SwitchStyle,
37};
38pub use hero::{Hero, HeroRegistry};
39pub use layout_builder::{collect_builder_constraints, expand_layout_builders, has_layout_builder};
40pub use render::*;
41pub use ripple::{Ripple, RippleRegistry};
42pub use semantics::{Role, SemanticsFlags, SemanticsSpec};
43
44/// Texto a pintar dentro de un nodo. Alineación por defecto `Center`
45/// (horizontal y vertical), apta para labels de botón. Para layouts tipo
46/// editor o párrafo, usar `.text_aligned(...)` con `Alignment::Start`.
47#[derive(Clone)]
48pub struct TextSpec {
49    pub content: String,
50    pub size_px: f32,
51    pub color: Color,
52    pub alignment: llimphi_text::Alignment,
53    /// `true` = forzar variante italic en la fuente activa. Default false.
54    pub italic: bool,
55    /// Peso de fuente CSS: 400 = normal, 700 = bold. parley elige la
56    /// variante más cercana de la familia activa (o la sintetiza). Se usa
57    /// tanto al **medir** como al **pintar**, así medida y dibujo coinciden.
58    /// Default 400.
59    pub weight: f32,
60    /// Límite de líneas (CSS `-webkit-line-clamp` / Flutter `maxLines`). `None`
61    /// = sin límite (envuelve libre). Cuando el texto excede, se trunca: con
62    /// [`Self::ellipsis`] la última línea termina en `…`, sin él se corta seco.
63    /// Afecta medida (taffy reserva el alto de N líneas) y pintado.
64    pub max_lines: Option<usize>,
65    /// Si `true` y `max_lines` trunca, la última línea visible termina en `…`.
66    /// Sin efecto si `max_lines` es `None`. Default false.
67    pub ellipsis: bool,
68    /// CSS-style font-family string (acepta lista con fallbacks). `None`
69    /// = la fuente default de parley.
70    pub font_family: Option<String>,
71    /// Múltiplo de interlínea (`line-height` / `font-size`). 1.2 es el
72    /// default que usaban todos los callers; puriy lo sobreescribe con el
73    /// valor computado de CSS. Se usa tanto al **medir** (para que taffy
74    /// reserve el alto correcto) como al **pintar**, así medida y dibujo
75    /// coinciden.
76    pub line_height: f32,
77    /// Colores por rango de **bytes** sobre `content`, para texto multicolor
78    /// (syntax highlighting) en una sola pasada de shaping. `None` = color
79    /// uniforme (`color`). Cuando es `Some`, el runtime usa
80    /// `Typesetter::layout_runs` + `draw_layout_runs`, y `color` actúa como
81    /// color por defecto de lo no cubierto por ningún run.
82    pub runs: Option<Vec<(usize, usize, Color)>>,
83    /// Subrayado activo. El runtime pinta la línea bajo la línea base usando
84    /// las métricas (`underline_offset`, `underline_size`) que parley deriva
85    /// de la fuente — así un texto a 12pt y otro a 24pt tienen un subrayado
86    /// proporcional sin que el caller calcule nada.
87    pub underline: bool,
88    /// Tachado activo. Mismo régimen que [`Self::underline`] pero sobre el
89    /// strikethrough metric — útil para listas to-do, items removidos en un
90    /// diff, precios viejos.
91    pub strikethrough: bool,
92    /// **Spans inline mixtos** (RichText): overrides de
93    /// tamaño/peso/italic/familia/color/underline/strikethrough por rango
94    /// de bytes (parley convention). `None` = texto uniforme (camino
95    /// `layout_clamped`); `Some([])` se trata como `None`. Cuando hay
96    /// spans, el runtime usa `Typesetter::layout_spans` (Layout<RunBrush>
97    /// con `max_width`/wrap) + `draw_layout_runs_xf`; los campos del
98    /// `TextSpec` son **defaults a nivel bloque** que cada span puede
99    /// sobreescribir. Tier 2 final de PARIDAD-FLUTTER (Bloque 13).
100    pub spans: Option<Vec<llimphi_text::TextSpan>>,
101    /// `letter-spacing`: px **extra** entre letras (CSS). 0 = normal. Afecta
102    /// shaping y medida. Sólo el camino uniforme (`layout_clamped`); el camino
103    /// de spans (RichText) lo ignora en v1.
104    pub letter_spacing: f32,
105    /// `word-spacing`: px **extra** entre palabras (CSS). 0 = normal. Mismo
106    /// régimen que [`Self::letter_spacing`].
107    pub word_spacing: f32,
108    /// `white-space: nowrap`/`pre`: si `true`, el texto **no envuelve** —
109    /// se shapea en una sola línea (`break_all_lines(None)`) sin importar el
110    /// ancho disponible, y desborda la caja (lo recorta `overflow: hidden` si
111    /// lo hay). Afecta medida (taffy reserva el ancho de la línea completa) y
112    /// pintado. Default false (wrap libre, comportamiento previo). Sólo el
113    /// camino uniforme (`layout_clamped`); el de spans (RichText) lo ignora en
114    /// v1, igual que el clamp.
115    pub no_wrap: bool,
116    /// `overflow-wrap: break-word`/`anywhere` (o `word-break: break-all`): si
117    /// `true`, una palabra más ancha que la caja se **parte** para que entre,
118    /// en vez de desbordar. Afecta medida (taffy puede reservar menos ancho) y
119    /// pintado. Default false (la palabra larga desborda — comportamiento
120    /// previo). Sólo el camino uniforme (`layout_clamped`); el de spans
121    /// (RichText) lo ignora en v1, igual que `no_wrap`/clamp.
122    pub overflow_wrap: bool,
123    /// `font-feature-settings` (+ `font-kerning` plegado como `kern`) en formato
124    /// CSS crudo (`"liga" 1, "kern" 0`). Vacío = default de la fuente. Afecta
125    /// shaping y medida (ligaduras/kerning cambian el ancho). Sólo el camino
126    /// uniforme (`layout_clamped`); el de spans (RichText) lo ignora en v1.
127    pub font_features: String,
128    /// `font-variation-settings` en formato CSS crudo (`"wght" 700`). Vacío =
129    /// ejes en su default. Mismo régimen que [`Self::font_features`].
130    pub font_variations: String,
131    /// `font-stretch`/`font-width` como **porcentaje** (100 = normal, <100
132    /// condensada, >100 expandida). Afecta shaping y medida (la face
133    /// condensada/expandida cambia el ancho). Sólo el camino uniforme
134    /// (`layout_clamped`); el de spans (RichText) lo ignora en v1.
135    pub font_width: f32,
136}
137
138/// Fase de un drag activo. `Move` se emite por cada `CursorMoved` con el
139/// delta desde el evento anterior; `End` se emite al soltar el botón.
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum DragPhase {
142    Move,
143    End,
144}
145
146/// Handler de drag. Recibe la fase + delta (`dx`, `dy`) **desde el evento
147/// anterior** (no acumulado desde el press). Devolver `None` deja el drag
148/// activo sin disparar Msg. `Arc<dyn Fn>` para que el runtime pueda
149/// clonarlo barato al iniciar el drag y mantenerlo vivo aunque el cache
150/// de la vista se regenere mientras tanto.
151pub type DragFn<Msg> = Arc<dyn Fn(DragPhase, f32, f32) -> Option<Msg> + Send + Sync>;
152
153/// Handler de drop. El runtime lo invoca cuando un drag activo se suelta
154/// sobre este nodo. Recibe el `payload` `u64` que el origen del drag
155/// declaró vía [`View::drag_payload`]. Devolver `None` ignora el drop.
156///
157/// Los IDs `u64` son opacos para el runtime: el widget elige una
158/// convención (índice de tile, hash del item, etc.) y el handler decide
159/// qué Msg emitir en función de ese ID.
160pub type DropFn<Msg> = Arc<dyn Fn(u64) -> Option<Msg> + Send + Sync>;
161
162/// Handler de click con posición. Recibe `(x_local, y_local, rect_w,
163/// rect_h)`: las dos primeras son la posición del cursor **relativa a
164/// la esquina superior-izquierda del nodo** y las dos últimas son el
165/// ancho/alto actual del nodo en pixels — útil cuando el caller
166/// necesita centrar o normalizar. Devolver `None` no dispara update.
167pub type ClickAtFn<Msg> = Arc<dyn Fn(f32, f32, f32, f32) -> Option<Msg> + Send + Sync>;
168
169/// Handler de rueda **local a un nodo**. Recibe el delta `(dx, dy)` en
170/// líneas lógicas (misma normalización que `App::on_wheel`: `dy` positivo
171/// = scroll hacia abajo). El runtime lo invoca cuando la rueda gira con el
172/// cursor sobre este nodo, ANTES de caer al `App::on_wheel` global: si el
173/// handler devuelve `Some(Msg)`, el evento se consume aquí. Permite áreas
174/// de scroll autocontenidas (el widget `scroll` lo usa) sin que cada app
175/// rutee la rueda a mano por su `Model`. Devolver `None` deja pasar el
176/// evento al `on_wheel` global.
177pub type ScrollFn<Msg> = Arc<dyn Fn(f32, f32) -> Option<Msg> + Send + Sync>;
178
179/// Handler de rueda con **reparto parcial** (scroll anidado real): recibe el
180/// delta `(dx, dy)` en líneas y devuelve `(msg, rest_x, rest_y)` — el `Msg`
181/// por lo que consumió (o `None` si no consumió nada) y el **sobrante** en la
182/// misma unidad, que el runtime sigue repartiendo al siguiente scrollable de
183/// la cadena (el ancestro). Un mismo evento puede así mover al hijo hasta su
184/// tope Y al padre con el resto, en el mismo frame. Ver
185/// [`View::on_scroll_take`] y [`consume_scroll_chain`].
186pub type ScrollTakeFn<Msg> = Arc<dyn Fn(f32, f32) -> (Option<Msg>, f32, f32) + Send + Sync>;
187
188/// Variante de [`DragFn`] que **conoce la posición inicial del press**
189/// relativa al rect del nodo. Útil cuando el caller necesita identificar
190/// qué entidad (Concepto, lemming, etc.) bajo el cursor agarró el drag.
191/// Recibe `(phase, dx, dy, initial_lx, initial_ly)`.
192pub type DragAtFn<Msg> = Arc<dyn Fn(DragPhase, f32, f32, f32, f32) -> Option<Msg> + Send + Sync>;
193
194/// Variante de [`DragFn`] que recibe la **velocidad del drag al soltarlo**
195/// (`vx`, `vy` en px/s). El runtime mide el desplazamiento sobre los
196/// últimos ~100 ms de movimiento (ventana móvil de hasta ocho samples)
197/// y la pasa en `DragPhase::End`. Durante `DragPhase::Move` ambas son
198/// `0.0` — la velocidad sólo es significativa al final. Permite
199/// **fling-desde-drag**: el caller arranca un ticker con esa velocidad y
200/// la decae con [`fling_step`](https://docs.rs/) hasta asentar. Reemplaza
201/// la estimación manual que antes tenía que llevar el caller con
202/// `Instant::now()` por su cuenta.
203pub type DragVelocityFn<Msg> =
204    Arc<dyn Fn(DragPhase, f32, f32, f32, f32) -> Option<Msg> + Send + Sync>;
205
206/// Fase de un **gesto continuo** (pinch-to-zoom de momento; rotación a futuro).
207/// El runtime emite `Begin` al iniciar el gesto, `Update` por cada cambio
208/// incremental y `End` al terminar. El camino de Ctrl+rueda (universal, sin
209/// trackpad) emite un único `Update` por click de rueda — no hay un "inicio"
210/// ni "fin" naturales, así que el handler debe tolerar `Update`s sueltos sin
211/// `Begin` previo (es lo común en desktop). El camino de trackpad
212/// (`PinchGesture`, sólo macOS/iOS) sí entrega `Begin`/`Update*`/`End`.
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
214pub enum GesturePhase {
215    Begin,
216    Update,
217    End,
218}
219
220/// Handler de gesto de **escala** (pinch-to-zoom). Recibe `(phase, factor,
221/// focal_x, focal_y)`:
222/// - `factor`: cambio de escala **incremental y multiplicativo** desde el
223///   evento anterior — `1.0` = sin cambio, `>1.0` agranda (zoom in), `<1.0`
224///   achica (zoom out). El caller acumula con `mi_zoom *= factor` y, si
225///   quiere, lo clampa a su rango. En `Begin`/`End` el factor es `1.0`.
226/// - `focal_x`/`focal_y`: punto focal del gesto **relativo a la esquina
227///   superior-izquierda del rect del nodo** (mismo espacio que los handlers
228///   `*_at`). Es el punto que debe quedar fijo bajo el cursor al hacer zoom —
229///   el caller lo usa para zoomear "hacia el cursor" en vez de hacia el
230///   centro. En Ctrl+rueda es la posición del cursor; en trackpad, idem.
231///
232/// Devolver `Some(Msg)` dispara una transición; `None` ignora el evento. El
233/// runtime lo resuelve con [`hit_test_scale`]: el nodo más al frente bajo el
234/// cursor que declare un `on_scale` consume el gesto. Es la base del zoom de
235/// los canvases (pineal/cosmos/nakui).
236pub type ScaleFn<Msg> = Arc<dyn Fn(GesturePhase, f32, f32, f32) -> Option<Msg> + Send + Sync>;
237
238/// Handler de gesto de **rotación** (trackpad, sólo macOS — winit no emite
239/// `RotationGesture` en Wayland/Windows). Análogo a [`ScaleFn`] pero el
240/// segundo argumento es el **delta de ángulo incremental en radianes**
241/// (positivo = horario) en lugar del factor de escala; `(focal_x, focal_y)`
242/// es el punto bajo el cursor relativo al rect del nodo. El nodo más al
243/// frente bajo el cursor que declare un `on_rotate` consume el gesto. Base
244/// para rotar canvases/imágenes con dos dedos. Ver [`View::on_rotate`].
245pub type RotateFn<Msg> = Arc<dyn Fn(GesturePhase, f32, f32, f32) -> Option<Msg> + Send + Sync>;
246
247/// Restricciones de tamaño que un [`LayoutBuilderFn`] recibe: las dimensiones
248/// del slot que el layout le asignó al nodo (en px físicos). Análogo a las
249/// `BoxConstraints` de Flutter `LayoutBuilder` / al `MediaQuery` pero **local
250/// al nodo** (no a la ventana). El builder construye su subárbol en función de
251/// esto — p. ej. una columna si `max_width < 600`, dos si es ancho.
252#[derive(Debug, Clone, Copy, PartialEq)]
253pub struct Constraints {
254    pub max_width: f32,
255    pub max_height: f32,
256}
257
258/// Constructor **diferido** de subárbol sensible al tamaño (Flutter
259/// `LayoutBuilder`). El runtime resuelve el tamaño del slot del nodo en una
260/// primera pasada de layout y luego invoca esta closure con esas
261/// [`Constraints`] para producir los hijos — así "construir distinto según el
262/// espacio disponible" deja de exigir conocer el tamaño al armar el `View`. Ver
263/// [`View::layout_builder`].
264pub type LayoutBuilderFn<Msg> = Arc<dyn Fn(Constraints) -> View<Msg> + Send + Sync>;
265
266/// Constructor **diferido** del contenido de un [`View::memo`] (Bloque 25).
267/// Produce el subárbol (un único `View`) que va DENTRO del nodo memo. El runtime
268/// lo llama sólo cuando `deps` (o el tamaño del slot) cambió; si no, reusa el
269/// `Mounted` cacheado sin reconstruir el `View`.
270pub type MemoBuilderFn<Msg> = Arc<dyn Fn() -> View<Msg> + Send + Sync>;
271
272/// Nodo **memoizado** (Bloque 25 de `PLAN-NUCLEO-RETENIDO.md`): un subárbol
273/// cuyo `View` NO se reconstruye —ni se re-monta ni se re-layoutea— si sus
274/// `deps` no cambiaron. El nodo memo lleva un `Style` con **tamaño definido**
275/// (length/percent, no auto): el runtime lo monta como hoja de ese tamaño,
276/// computa el subárbol en un `LayoutTree` aislado (que taffy cachea) y splica
277/// su `Mounted` cacheado. `builder` = `Some` mientras está sin resolver;
278/// `resolve_memos` lo extrae y el nodo queda como marcador. Ver [`View::memo`].
279pub struct MemoNode<Msg> {
280    pub key: u64,
281    pub deps: u64,
282    pub builder: Option<MemoBuilderFn<Msg>>,
283}
284
285/// Rect absoluto del nodo (en coordenadas físicas del frame). Lo
286/// recibe el callback de [`View::paint_with`] para que pueda
287/// posicionar sus primitivas custom dentro del nodo.
288#[derive(Debug, Clone, Copy, Default)]
289pub struct PaintRect {
290    pub x: f32,
291    pub y: f32,
292    pub w: f32,
293    pub h: f32,
294}
295
296/// Callback de pintura custom. El runtime lo invoca durante el paint
297/// del nodo (entre el `fill`/`image` y el `text`) con el `Scene` vivo
298/// + el `Typesetter` cacheado del runtime + el rect absoluto del nodo.
299/// Pensado para "canvas elements" tipo `dominium-canvas`,
300/// `pluma-editor` (osciloscopio de coherencia), `cosmos` (charts).
301///
302/// El `Typesetter` se pasa porque crearlo por frame es caro
303/// (`FontContext::new` enumera las fontes del sistema vía fontique).
304/// Los callers que no necesiten texto pueden ignorar el argumento.
305///
306/// El callback no debe llamar a `scene.push_layer` sin un `pop_layer`
307/// correspondiente, ni reset el scene — sólo agregar primitivas que
308/// pertenezcan al rect del nodo.
309pub type PaintFn = Arc<
310    dyn Fn(&mut vello::Scene, &mut llimphi_text::Typesetter, PaintRect) + Send + Sync,
311>;
312
313/// Callback de pintura GPU directo, sin vello intermedio. Recibe el
314/// `device`/`queue` ya construidos por el runtime más un
315/// `CommandEncoder` y la `TextureView` del frame (la intermediate
316/// `Rgba8Unorm` de `WinitSurface`), todo durante el paint del nodo.
317///
318/// El caller abre su propio `begin_render_pass` con `LoadOp::Load` para
319/// no sobrescribir lo que ya pintó vello, dibuja sus primitivas y
320/// cierra el pass. El runtime se encarga de dispatchear (`queue.submit`)
321/// el encoder ya con todas las pasadas de todos los nodos acumuladas —
322/// es un solo submit por frame.
323///
324/// **Orden de pintura en Fase 1**: todos los `gpu_painter` corren
325/// DESPUÉS de la pasada completa de vello (fill, image, painter,
326/// text) sobre el `mounted` tree. Entre sí mantienen el orden DFS
327/// pre-orden. Si una app necesita pintar texto **encima** del render
328/// GPU directo, la forma idiomática es ponerlo en `App::view_overlay`,
329/// que se renderiza como una segunda Scene de vello encima de todo.
330///
331/// Pensado para apps con volumen masivo de primitivos (cosmos
332/// starfield Gaia, tinkuy particle viewer, nakui viewport, pineal
333/// denso) — el hook que paga el costo de mantener pipelines WGSL
334/// propias en `llimphi-raster` (ver `02_ruway/llimphi/SDD.md`
335/// §"Roadmap — GPU directo wgpu").
336pub type GpuPaintFn = Arc<
337    dyn Fn(
338            &wgpu::Device,
339            &wgpu::Queue,
340            &mut wgpu::CommandEncoder,
341            &wgpu::TextureView,
342            PaintRect,
343            (u32, u32),
344        ) + Send
345        + Sync,
346>;
347
348/// Callback de pintura vello "over": idéntico en firma a [`PaintFn`]
349/// `(&mut Scene, &mut Typesetter, PaintRect)`, pero el runtime lo invoca
350/// en una pasada vello FINAL, **después** de todos los `gpu_painter` del
351/// frame. Sus primitivas se rasterizan sobre fondo transparente y se
352/// componen con alpha encima de la intermedia (que ya tiene
353/// vello-base + GPU directo). Resuelve el z-order inverso al de
354/// [`GpuPaintFn`]: permite pintar texto/sprites AA por vello **encima**
355/// de celdas instanciadas por GPU (dominium grid, futuro motor voxel).
356///
357/// Orden total del frame: `[vello base] → [gpu_painter] → [over_painter]
358/// → [overlay/menús]`. Los menús (`view_overlay`) siguen quedando por
359/// encima del over-layer. Ver [`View::paint_over`]. Es un alias de
360/// [`PaintFn`]; existe sólo para documentar la semántica temporal.
361pub type OverPaintFn = PaintFn;
362
363/// Sombra proyectada detrás del rect del nodo (drop shadow), rasterizada
364/// con el `draw_blurred_rounded_rect` nativo de vello. Se pinta **antes**
365/// del relleno, así el fill (si es opaco) tapa la parte solapada y la
366/// sombra sólo asoma por el desenfoque + el offset. El radio sigue al del
367/// nodo (más `spread`).
368#[derive(Clone, Copy, Debug, PartialEq)]
369pub struct Shadow {
370    pub color: Color,
371    /// Desviación estándar del gaussiano (qué tan difusa). En px.
372    pub blur: f64,
373    /// Desplazamiento de la sombra respecto del nodo.
374    pub dx: f64,
375    pub dy: f64,
376    /// Cuánto crece (px) el rect de la sombra respecto del nodo.
377    pub spread: f64,
378}
379
380impl Shadow {
381    /// Sombra con color + blur explícitos, sin offset ni spread.
382    pub fn new(color: Color, blur: f64) -> Self {
383        Self { color, blur, dx: 0.0, dy: 0.0, spread: 0.0 }
384    }
385
386    /// Elevación suave y tasteful: negro translúcido, leve caída hacia
387    /// abajo. El default razonable para cards/menús/modales.
388    pub fn soft(alpha: u8, blur: f64) -> Self {
389        Self {
390            color: Color::from_rgba8(0, 0, 0, alpha),
391            blur,
392            dx: 0.0,
393            dy: blur * 0.4,
394            spread: 0.0,
395        }
396    }
397
398    pub fn offset(mut self, dx: f64, dy: f64) -> Self {
399        self.dx = dx;
400        self.dy = dy;
401        self
402    }
403
404    pub fn spread(mut self, spread: f64) -> Self {
405        self.spread = spread;
406        self
407    }
408}
409
410/// Borde (stroke) pintado sobre el contorno redondeado del nodo, **inset**
411/// hacia adentro media línea para que el grosor quede dentro del rect
412/// (convención CSS `box-sizing: border-box`). Se pinta después del relleno.
413#[derive(Clone, Copy, Debug)]
414pub struct Border {
415    pub width: f64,
416    pub color: Color,
417}
418
419impl Border {
420    pub fn new(width: f64, color: Color) -> Self {
421        Self { width, color }
422    }
423}
424
425/// Una operación de filtro CSS (`filter: blur()/brightness()/…`) aplicada al
426/// **propio subárbol** del nodo. A diferencia de `backdrop_blur` (que afecta lo
427/// pintado *debajo*), un `FilterOp` modifica el contenido del nodo. El runtime
428/// los aplica como post-pasada GPU sobre la intermediate, restringidos al rect
429/// del nodo, en el orden de la lista. La lista crece por fase (CSS Filter
430/// Effects 1): `Blur` (7.1232) + `ColorMatrix` (7.1233). Fase 7.1232.
431#[derive(Clone, Debug, PartialEq)]
432pub enum FilterOp {
433    /// `filter: blur(<px>)`. `px` es la desviación estándar del Gauss (igual
434    /// convención que CSS). Se aplica con `BlurCompositor`, el mismo camino que
435    /// `backdrop_blur`.
436    Blur(f32),
437    /// Filtros de color (`brightness`/`contrast`/`grayscale`/`sepia`/`saturate`/
438    /// `invert`/`hue-rotate`/`opacity`) colapsados a una **matriz de color 4×5**
439    /// row-major: por fila `[c0, c1, c2, c3, bias]`, salida R/G/B/A
440    /// (`out = M·rgba + bias`). Se aplica con `ColorFilterCompositor`. Fase
441    /// 7.1233.
442    ColorMatrix([f32; 20]),
443    /// `filter: drop-shadow(<ox> <oy> [blur] [color])`. Se pinta como una sombra
444    /// Gaussiana del **border-box** detrás del nodo (con `draw_blurred_rounded_rect`,
445    /// igual primitiva que `Shadow`/box-shadow). v1: sombra del rect, no de la
446    /// silueta alpha del subárbol. A diferencia de `Blur`/`ColorMatrix`, NO es
447    /// post-pasada GPU — se pinta en vello antes del relleno, por lo que
448    /// `collect_filters` la ignora. Fase 7.1234.
449    DropShadow(Shadow),
450}
451
452/// Punto de pivote de `transform` (CSS `transform-origin`). Cada eje se resuelve
453/// contra el rect del nodo como `px + frac · tamaño`: `px` (ya escalado por zoom
454/// por el caller) cubre offsets absolutos y `frac` los porcentuales (`0.5` = 50%
455/// del ancho/alto). El default CSS `50% 50%` (centro) es
456/// `{ px: (0.0, 0.0), frac: (0.5, 0.5) }`; un nodo con `transform_origin: None`
457/// usa ese centro. Modela `px + %` por eje igual que `transform_rel` modela el
458/// `translate(<%>)` — necesario porque el % depende del layout, desconocido hasta
459/// `paint`.
460#[derive(Debug, Clone, Copy, PartialEq)]
461pub struct TransformPivot {
462    /// Offset absoluto en px (ya × zoom) por eje `(x, y)`.
463    pub px: (f64, f64),
464    /// Fracción del tamaño del rect por eje `(x, y)` (`0.5` = 50%).
465    pub frac: (f64, f64),
466}
467
468impl Default for TransformPivot {
469    fn default() -> Self {
470        // CSS `transform-origin: 50% 50%` — centro del rect.
471        Self { px: (0.0, 0.0), frac: (0.5, 0.5) }
472    }
473}
474
475/// Nodo de la vista declarativa. Estilo de layout (taffy) + relleno opcional
476/// (vello) + texto opcional (skrifa+vello) + Msg al click opcional + hijos.
477pub struct View<Msg> {
478    pub style: Style,
479    pub fill: Option<Color>,
480    /// Relleno cuando el cursor está sobre este nodo. Sin valor (`None`)
481    /// = no se reacciona al hover.
482    pub hover_fill: Option<Color>,
483    pub radius: f64,
484    /// Radio **por esquina** (top-left, top-right, bottom-right, bottom-left),
485    /// que sobreescribe a `radius` cuando está presente. Permite cards con
486    /// sólo las esquinas de arriba redondeadas, pestañas, bocadillos de chat,
487    /// etc. (CSS `border-radius` con 4 valores). `None` = usar el `radius`
488    /// uniforme. Ver [`View::radius_corners`]. La **sombra** sigue usando un
489    /// radio escalar (el blur nativo de vello no acepta radios por esquina);
490    /// el **borde** sí respeta las cuatro esquinas.
491    pub corner_radii: Option<RoundedRectRadii>,
492    /// Sombra proyectada detrás del nodo (drop shadow). `None` = sin sombra
493    /// (la mayoría de nodos). Ver [`Shadow`].
494    pub shadow: Option<Shadow>,
495    /// Relleno con **gradiente**, autoreado en el cuadrado unidad `[0,1]²` y
496    /// mapeado al rect del nodo. Gana sobre `fill` como base; `hover_fill`
497    /// (un color) lo sigue overrideando en hover. Ver [`View::fill_gradient`].
498    pub fill_gradient: Option<Gradient>,
499    /// Borde (stroke) sobre el contorno redondeado. Ver [`Border`].
500    pub border: Option<Border>,
501    pub text: Option<TextSpec>,
502    /// Imagen a pintar dentro del rect del nodo. Se centra y escala
503    /// según [`Self::image_fit`] (default `Contain` = preservar
504    /// aspect ratio cabiendo). El alfa por píxel de la imagen y el
505    /// `Image::alpha` global se respetan; el `fill` (si lo hay) se
506    /// pinta debajo como background. El clip al `node_rrect` respeta
507    /// `radius`/`corner_radii`, así avatares y cards con esquinas
508    /// redondeadas funcionan sin envolver en un padre `clip(true)`.
509    pub image: Option<Image>,
510    /// Política de encaje de [`Self::image`] en el rect del nodo
511    /// (CSS `object-fit`). `None` = `Contain` (el default histórico).
512    /// Ver [`ImageFit`] y [`View::image_fit`].
513    pub image_fit: Option<ImageFit>,
514    /// **Máscara de luminancia** (CSS `mask-image`). Si está presente, el
515    /// runtime aísla el subárbol del nodo en una capa y luego lo enmascara con
516    /// la luminancia de esta imagen (`push_luminance_mask_layer` de vello):
517    /// blanco = visible, negro = oculto, gris = semitransparente. El encaje lo
518    /// fija [`Self::mask_placement`] (size/position/repeat); sin él la imagen se
519    /// estira al border-box. `None` = sin máscara. Ver [`View::mask_image`].
520    pub mask_image: Option<Image>,
521    /// Encaje de [`Self::mask_image`] (CSS `mask-size`/`-position`/`-repeat`).
522    /// `None` = estirar al border-box (Fase 7.1226). Sólo se consulta si
523    /// `mask_image` está presente. Ver [`MaskPlacement`] y
524    /// [`View::mask_placement`]. Fase 7.1227.
525    pub mask_placement: Option<MaskPlacement>,
526    /// Capas de máscara ADICIONALES (`mask-image: url(a), url(b), …`): cada una
527    /// es `(imagen, operador)`. Comparten [`Self::mask_placement`] con la capa 0
528    /// ([`Self::mask_image`]); se combinan con ella según el operador. Vacío =
529    /// una sola capa. Ver [`View::mask_extra`]. Fase 7.1231.
530    pub mask_extra: Vec<(Image, MaskCompose)>,
531    /// Callback de pintura custom. Si está presente, el runtime lo
532    /// invoca durante el paint del nodo con el `Scene` vivo + el rect
533    /// absoluto. Pensado para "canvas elements" (dominium, pluma,
534    /// cosmos) que pintan primitivas custom no expresables como una
535    /// composición de Views.
536    pub painter: Option<PaintFn>,
537    /// Pintor GPU directo. Se invoca DESPUÉS de la pasada vello del
538    /// frame; comparte tree y orden DFS con los demás. Ver
539    /// [`GpuPaintFn`].
540    pub gpu_painter: Option<GpuPaintFn>,
541    /// Pintor vello "over": closure que pinta DESPUÉS del pase GPU del
542    /// frame, sobre una escena vello que el runtime compone con alpha
543    /// encima de la intermedia. Sirve para sprites/texto AA encima de
544    /// celdas instanciadas por GPU. Ver [`View::paint_over`] y
545    /// [`OverPaintFn`]. Misma firma que [`PaintFn`] — sólo cambia
546    /// *cuándo* corre (post-GPU). `None` = sin over-layer (coste cero).
547    pub over_painter: Option<PaintFn>,
548    /// **Subárbol over** (ver [`View::over`]): este nodo y todos sus
549    /// descendientes se pintan en la pasada vello FINAL, después del pase
550    /// GPU directo — con toda la maquinaria normal (fills, texto, imágenes,
551    /// painters, hover). Es el primitivo para UI **encima** de contenido
552    /// `gpu_paint_with` (OSC de un reproductor, HUD sobre un canvas GPU).
553    /// El hit-test no cambia (usa el layout, no la pasada de pintura).
554    /// `false` = pinta en la pasada base, como siempre (coste cero).
555    pub over_layer: bool,
556    pub on_click: Option<Msg>,
557    /// Handler de click que recibe la posición **relativa al rect del
558    /// nodo** (esquina superior-izquierda del nodo = `(0, 0)`). Útil
559    /// para canvas elements que quieren mapear el click a coordenadas
560    /// de mundo. Si está presente, gana sobre `on_click`. Devolver
561    /// `None` no dispara update.
562    pub on_click_at: Option<ClickAtFn<Msg>>,
563    /// Gemelo de izquierda de [`Self::on_right_click_screen`]: click izquierdo con
564    /// coords **absolutas de ventana**, para anclar un popup (select/dropdown) en
565    /// la capa `view_overlay`.
566    pub on_click_screen: Option<ClickAtFn<Msg>>,
567    /// Equivalente a `on_click` pero para el botón derecho del ratón.
568    /// Pensado para menús contextuales: el nodo declara qué `Msg`
569    /// emitir cuando se le hace right-click, y la app abre el overlay
570    /// con el menú.
571    pub on_right_click: Option<Msg>,
572    /// Variante posicional de [`Self::on_right_click`]. Útil para
573    /// grillas que necesitan saber *qué celda* del rect recibió el
574    /// click derecho (la celda no es un nodo aparte, sino una región
575    /// dentro del nodo). Si está presente, gana sobre `on_right_click`.
576    pub on_right_click_at: Option<ClickAtFn<Msg>>,
577    /// Como [`Self::on_right_click_at`] pero recibe coords **absolutas de
578    /// ventana** (no locales al nodo). Para anclar menús contextuales en la
579    /// posición del puntero: `on_right_click_at` (local) sirve a grillas/canvas
580    /// que necesitan saber QUÉ región del rect se clickeó; éste sirve a popups
581    /// que quieren la posición en pantalla. Los backends le pasan el cursor
582    /// absoluto. Convive con `on_right_click_at` (un nodo puede tener ambos).
583    pub on_right_click_screen: Option<ClickAtFn<Msg>>,
584    /// Equivalente a `on_click` pero para el botón del medio del ratón
585    /// (rueda presionada). Pensado para abrir en pestaña nueva — los
586    /// browsers usan middle-click como atajo equivalente a Ctrl+Click.
587    pub on_middle_click: Option<Msg>,
588    /// Handler de drag. Si está presente, este nodo arrastra (y NO emite
589    /// `on_click` al presionar — un nodo es uno u otro).
590    pub drag: Option<DragFn<Msg>>,
591    /// Variante de drag que recibe la posición inicial del press relativa
592    /// al rect del nodo. Gana sobre `drag` si ambos están presentes.
593    pub drag_at: Option<DragAtFn<Msg>>,
594    /// Variante de drag que recibe la **velocidad** al soltar (`vx`, `vy`
595    /// en px/s) además del delta puntual. Gana sobre `drag`/`drag_at`
596    /// cuando está presente — un nodo elige un único sabor de drag. Habilita
597    /// fling-desde-drag (el caller arranca un ticker con esa velocidad y la
598    /// decae con [`fling_step`]).
599    pub drag_velocity: Option<DragVelocityFn<Msg>>,
600    /// Payload `u64` que viaja con el drag iniciado sobre este nodo. Lo
601    /// recibe el handler [`Self::on_drop`] del drop target. Sin payload,
602    /// el drag funciona igual pero ningún drop target reacciona.
603    pub drag_payload: Option<u64>,
604    /// Handler invocado al soltar un drag sobre este nodo (drop target).
605    pub on_drop: Option<DropFn<Msg>>,
606    /// Color a pintar mientras un drag activo está hovereando este drop
607    /// target. Sobrepone a `fill`/`hover_fill` cuando aplica.
608    pub drop_hover_fill: Option<Color>,
609    /// Si `true`, los descendientes se recortan al rect del nodo (vía
610    /// `scene.push_layer` con `Mix::Clip`). El hit-test también respeta
611    /// el recorte: clicks fuera del rect ignoran a los hijos.
612    pub clip: bool,
613    /// Si `Some([top, right, bottom, left])`, recorta los descendientes a un
614    /// rect ENCOGIDO por esos insets (px) desde el rect del nodo — modela
615    /// `clip-path: inset(...)`. Implica clip aunque `clip == false`.
616    pub clip_inset: Option<[f32; 4]>,
617    /// Si `Some(spec)` (14 floats), recorta los descendientes a una ELIPSE —
618    /// modela `clip-path: circle()`/`ellipse()`. El centro (4) se resuelve
619    /// contra el rect: `cx = cx_px + cx_pct/100·w`, `cy = cy_px +
620    /// cy_pct/100·h`. Cada radio (5: `[px, pct_w, pct_h, pct_diag, side]`) con
621    /// `side == 0` suma `px + pct_w/100·w + pct_h/100·h + pct_diag/100·diag`
622    /// (`diag = √(w²+h²)/√2`); con `side != 0` se computa desde la distancia
623    /// del centro a los bordes (`1`/`2` = closest/farthest sobre los 4 lados;
624    /// `3`/`4` = ídem sobre el eje del radio). Layout: `[cx×2, cy×2, rx×5,
625    /// ry×5]`. Implica clip aunque `clip == false`. Si conviven `clip_inset` y
626    /// `clip_ellipse`, gana la elipse (una sola capa de recorte por nodo).
627    pub clip_ellipse: Option<[f32; 14]>,
628    /// Si `Some((evenodd, puntos))`, recorta los descendientes a un POLÍGONO —
629    /// modela `clip-path: polygon()`. Cada punto `[x_px, x_pct, y_px, y_pct]`
630    /// resuelve `(x_px + x_pct/100·w, y_px + y_pct/100·h)` contra el rect.
631    /// `evenodd` elige la regla de relleno. Implica clip aunque `clip ==
632    /// false`. Prioridad de recorte por nodo: polygon > elipse > inset > rect.
633    pub clip_polygon: Option<(bool, Vec<[f32; 4]>)>,
634    /// Si `Some((evenodd, d))`, recorta los descendientes a un PATH SVG —
635    /// modela `clip-path: path()`. `d` es el string SVG crudo (user units px,
636    /// relativos al origen del rect); el pintado lo parsea con
637    /// `BezPath::from_svg` y lo traslada al origen del nodo. Si el parseo
638    /// falla, no recorta. Implica clip aunque `clip == false`. Prioridad:
639    /// path > polygon > elipse > inset > rect.
640    pub clip_path_svg: Option<(bool, String)>,
641    /// Si `Some([t,r,b,l])`, el clip-path se resuelve contra una caja de
642    /// referencia (`<geometry-box>`) que es el rect del nodo ENCOGIDO por esos
643    /// insets px (padding-box = border; content-box = border+padding). El
644    /// pintado lo aplica ANTES de resolver la forma; sin forma, recorta a ese
645    /// rect. `None` = referencia = border-box (rect completo). Fase 7.1225.
646    pub clip_ref_inset: Option<[f32; 4]>,
647    /// Msg a emitir cuando el cursor entra al rect del nodo (transición
648    /// no-hover → hover). Útil para previews tipo "URL del link al
649    /// pasar el mouse".
650    pub on_pointer_enter: Option<Msg>,
651    /// Msg a emitir cuando el cursor sale del rect del nodo.
652    pub on_pointer_leave: Option<Msg>,
653    /// Handler de **movimiento del cursor** sobre el nodo: recibe `(local_x,
654    /// local_y, rect_w, rect_h)` en CADA `CursorMoved` mientras el cursor está
655    /// encima (no sólo en la transición de entrada, a diferencia de
656    /// [`Self::on_pointer_enter`]). Análogo posicional de hover, base de cosas
657    /// como el thumbnail que sigue al cursor sobre un timeline o un drawer que
658    /// reacciona a la posición. `None` no dispara update.
659    pub on_pointer_move_at: Option<ClickAtFn<Msg>>,
660    /// Handler de rueda local. Si está presente y el cursor cae sobre este
661    /// nodo, el runtime lo invoca antes del `App::on_wheel` global; un
662    /// `Some(Msg)` consume el evento. Base de las áreas de scroll
663    /// autocontenidas. Ver [`ScrollFn`].
664    pub on_scroll: Option<ScrollFn<Msg>>,
665    /// Variante con **reparto parcial** del delta (scroll anidado real):
666    /// consume lo que puede y devuelve el sobrante, que sigue al ancestro.
667    /// Tiene prioridad sobre `on_scroll` si ambos están. Ver [`ScrollTakeFn`].
668    pub on_scroll_take: Option<ScrollTakeFn<Msg>>,
669    /// Handler de gesto de **escala** (pinch-to-zoom). Si está presente y el
670    /// gesto cae sobre este nodo (Ctrl+rueda en desktop, pinch de trackpad en
671    /// macOS), el runtime lo invoca con el factor incremental + el punto focal
672    /// local. Base del zoom de canvases. Ver [`ScaleFn`] y [`View::on_scale`].
673    pub on_scale: Option<ScaleFn<Msg>>,
674    /// Handler de gesto de **rotación** (dos dedos en trackpad, macOS). Si
675    /// está presente y el gesto cae sobre este nodo, el runtime lo invoca con
676    /// el delta de ángulo incremental (radianes) + el punto focal local. Ver
677    /// [`RotateFn`] y [`View::on_rotate`].
678    pub on_rotate: Option<RotateFn<Msg>>,
679    /// Msg a emitir en **doble-tap** (dos presses izquierdos sobre este nodo
680    /// dentro de una ventana temporal corta y muy cerca). Es un evento
681    /// **aditivo**: si el nodo también tiene `on_click`, éste igual dispara en
682    /// cada press; el doble-tap llega además en el segundo. Para doble-tap
683    /// exclusivo, pon el handler en un nodo sin `on_click`. Ver
684    /// [`View::on_double_tap`].
685    pub on_double_tap: Option<Msg>,
686    /// Variante posicional de [`Self::on_double_tap`]: recibe la posición del
687    /// segundo tap relativa al rect del nodo (para zoom-to-point, etc.). Gana
688    /// sobre `on_double_tap` si ambos están.
689    pub on_double_tap_at: Option<ClickAtFn<Msg>>,
690    /// Msg a emitir en **long-press** (mantener el botón izquierdo sobre este
691    /// nodo ~500 ms sin moverse ni soltar). El runtime lo arbitra por tiempo:
692    /// si el cursor se aleja (pasó a drag/scroll) o se suelta antes, se
693    /// cancela. Evento **aditivo** (ver [`Self::on_double_tap`]); el caso
694    /// limpio es un nodo con drag-to-pan + long-press y sin `on_click` (un
695    /// canvas). Útil para menús contextuales táctiles / selección. Ver
696    /// [`View::on_long_press`].
697    pub on_long_press: Option<Msg>,
698    /// Variante posicional de [`Self::on_long_press`]: recibe la posición del
699    /// press relativa al rect del nodo (para abrir el menú en el punto). Gana
700    /// sobre `on_long_press` si ambos están.
701    pub on_long_press_at: Option<ClickAtFn<Msg>>,
702    /// Marca este nodo como **enfocable** con el id opaco `u64`. El runtime
703    /// mantiene el foco (uno por ventana) y lo mueve con Tab/Shift+Tab en
704    /// orden de árbol (pre-orden) y al clickear un nodo enfocable; notifica
705    /// a la app vía `App::on_focus` para que pinte el ring y rutee el
706    /// teclado. El id lo elige el caller (índice de campo, hash, etc.).
707    pub focusable: Option<u64>,
708    /// Marca este nodo de **texto** como seleccionable con el mouse fuera del
709    /// editor (arrastrar resalta, Ctrl/Cmd+C copia). El `u64` es una **key
710    /// estable** entre rebuilds del `View` (los `NodeId` de taffy cambian cada
711    /// frame, así que la selección retenida en el runtime se ancla a esta key,
712    /// igual que `animated`). Sólo tiene efecto en nodos con `text` uniforme
713    /// (no `runs`/`spans`). Ver [`View::selectable`].
714    pub text_select_key: Option<u64>,
715    /// Opacidad multiplicada sobre TODO el subtree (este nodo + hijos),
716    /// en `[0.0, 1.0]`. Se realiza con `scene.push_layer(Mix::Normal, a, …)`
717    /// alrededor del rect del nodo: el subárbol se rasteriza en una capa
718    /// intermedia y se compone al alfa indicado contra lo que ya hay
719    /// detrás. `None` = sin capa (caso de la abrumadora mayoría de
720    /// nodos). Útil para fade-in/out de overlays, ghosts mientras se
721    /// arrastra, modales que aparecen, panels "vidrio". Note que la
722    /// composición tiene costo (allocate + blit), por lo que sólo
723    /// poblar este slot cuando hace falta — no es un atributo gratis.
724    pub alpha: Option<f32>,
725    /// Animación **implícita** de las props de paint (fill/radius): cuando el
726    /// valor cambia entre frames, el runtime interpola en vez de saltar. `None`
727    /// = sin animación (la abrumadora mayoría). La `key` debe ser estable entre
728    /// rebuilds. Ver [`Anim`] y [`View::animated`]. Lo consume el runtime vía
729    /// [`AnimRegistry::reconcile`] (DESPUÉS de layout, ANTES de paint).
730    pub anim: Option<Anim>,
731    /// **Animación implícita de tamaño** (Flutter `AnimatedSize` /
732    /// Compose `animateContentSize()`). `None` = sin animación. La key
733    /// debe ser estable entre rebuilds. A diferencia de [`Self::anim`]
734    /// (props de paint, reconcilia DESPUÉS de layout), el tamaño tiene
735    /// que estar firme **antes** del layout — siblings/hijos dependen
736    /// del rect del nodo. El runtime llama
737    /// [`reconcile_size_anim`] sobre el `View` tree **antes** de
738    /// `mount` y parcha `style.size` con el valor interpolado. Sólo se
739    /// activa si ambos `style.size.width` y `style.size.height` son
740    /// `Dimension::Length(_)`. Ver [`SizeAnim`] y [`View::animated_size`].
741    pub animated_size: Option<SizeAnim>,
742    /// **Semántica accesible** del nodo (rol, label, value, flags ARIA). El
743    /// runtime la traduce a un árbol AccessKit por frame para alimentar
744    /// lectores de pantalla (NVDA/VoiceOver/Orca/TalkBack). `None` = no
745    /// declarada (el lector lee el texto plano si lo hay, sin rol específico).
746    /// Ver [`SemanticsSpec`].
747    pub semantics: Option<SemanticsSpec>,
748    /// **Hero shared-element**: marca este nodo como una identidad estable
749    /// entre frames. Si la misma `key` aparece en otra posición en un frame
750    /// siguiente, el runtime interpola `transform` para "volar" del rect
751    /// anterior al actual durante la `duration` declarada. Ver
752    /// [`Hero`] y [`HeroRegistry`]. `None` = sin hero (la abrumadora mayoría).
753    pub hero: Option<Hero>,
754    /// Transformación afín 2D aplicada a este nodo y todo su subtree
755    /// **alrededor del centro de su propio rect** (convención CSS
756    /// `transform-origin: 50% 50%`). El runtime resuelve el centro en
757    /// `paint` (sólo entonces conoce el layout computado) y compone
758    /// `T(centro) · transform · T(-centro)` sobre la transformación
759    /// acumulada del padre, así nodos anidados transforman en el espacio
760    /// ya transformado de su ancestro — igual que CSS. `None` = identidad
761    /// (la abrumadora mayoría de nodos). Pensado para `transform`/
762    /// `@keyframes` CSS de puriy (rotate/scale/translate). El hit-test
763    /// **respeta** el afín (un nodo transformado recibe clicks donde se ve
764    /// pintado). Limitación restante: los `painter`/`runs` custom no heredan
765    /// el afín, y la posición local que reciben los handlers `*_at` se
766    /// reporta en espacio de pantalla, no en el espacio local del nodo.
767    pub transform: Option<Affine>,
768    /// Traslación RELATIVA al tamaño del propio nodo, en fracciones de su rect
769    /// computado: `(fx, fy)` ⇒ desplaza `(fx · w, fy · h)` px. Se resuelve en
770    /// `paint`/`hit_test` (única instancia donde se conoce el tamaño usado) y
771    /// se compone como el factor más externo del afín del nodo, ANTES del
772    /// centrado por `transform-origin`. Pensado para el `translate(<%>)` de CSS
773    /// (p. ej. el truco de centrado `translate(-50%, -50%)` ⇒ `(-0.5, -0.5)`),
774    /// que no es expresable como `Affine` fijo porque el % depende del layout.
775    /// `None` = sin traslación relativa (la abrumadora mayoría). Compone con
776    /// `transform` (afín fijo) si ambos están: `T_rel · transform`.
777    pub transform_rel: Option<(f64, f64)>,
778    /// Punto de pivote de `transform` (CSS `transform-origin`). `None` ⇒ el
779    /// default CSS `50% 50%` (centro del rect) — el caso mayoritario. Ver
780    /// [`TransformPivot`] y [`View::transform_origin`].
781    pub transform_origin: Option<TransformPivot>,
782    /// `position: sticky` (CSS): insets `[top, right, bottom, left]` en px
783    /// relativos al **scrollport** (el ancestro que recorta, `clip==true`).
784    /// El nodo se pinta en su posición de flujo normal hasta que el scroll lo
785    /// lleva al umbral del inset; ahí se "pega" a esa distancia del borde del
786    /// scrollport, sin salir de él. Como el scroll ya está horneado en el
787    /// layout (el rect del nodo refleja el desplazamiento), `paint` resuelve el
788    /// enganche con pura aritmética de rects — no necesita conocer el offset de
789    /// scroll. `None` ⇒ nodo no-sticky (la abrumadora mayoría). Ver
790    /// [`View::sticky`]. Limitación v1: el clamp usa el scrollport como caja
791    /// contenedora (no el bloque contenedor CSS exacto); alcanza para el caso
792    /// canónico (header/columna sticky a nivel de página).
793    pub sticky: Option<[Option<f32>; 4]>,
794    /// Texto de **tooltip**: si está, el runtime/cliente puede mostrar un
795    /// rótulo flotante cuando el cursor se posa sobre este nodo. Llimphi sólo
796    /// transporta el dato hasta el [`MountedNode`]; *quién* lo pinta (un overlay
797    /// del runtime, una surface popup del cliente) lo decide el consumidor. El
798    /// hit-test de hover ya localiza el nodo bajo el cursor. `None` = sin tip.
799    pub tooltip: Option<String>,
800    /// Forma del puntero del mouse mientras está sobre este nodo (o un
801    /// descendiente sin cursor propio — se hereda del ancestro más cercano que
802    /// lo declare). El runtime lo resuelve en el hit-test de hover y lo aplica a
803    /// la ventana. `None` = hereda (default flecha en la raíz). Ver [`Cursor`] y
804    /// [`View::cursor`]. Llimphi-native (sin winit); el runtime lo mapea.
805    pub cursor: Option<Cursor>,
806    /// Feedback de tap **ripple/InkWell**: al presionar este nodo, el runtime
807    /// emite una salpicadura Material (círculo que se expande desde el punto y
808    /// se desvanece, recortado al contorno del nodo). Es puro feedback visual,
809    /// aditivo al `on_click`; vive en el runtime ([`RippleRegistry`]), no en el
810    /// `Model`. `None` = sin ripple. Ver [`View::ripple`].
811    pub ripple: Option<Ripple>,
812    /// Constructor **diferido** sensible al tamaño (`LayoutBuilder`). Si está
813    /// presente, este nodo NO usa sus `children` estáticos: el runtime resuelve
814    /// su slot en una primera pasada de layout y luego invoca esta closure con
815    /// las [`Constraints`] resueltas para producir el subárbol. `None` = nodo
816    /// normal (la abrumadora mayoría). Ver [`View::layout_builder`].
817    pub layout_builder: Option<LayoutBuilderFn<Msg>>,
818    /// Backdrop blur sobre el contenido pintado **debajo** de este nodo.
819    /// Ver [`View::backdrop_blur`] / [`MountedNode::backdrop_blur`]. v1:
820    /// sólo se aplica a nodos top-level sin clip/alpha ancestral.
821    pub backdrop_blur: Option<f32>,
822    /// Filtros CSS (`filter: …`) sobre el propio subárbol del nodo. Vacío = sin
823    /// filtro. Ver [`View::filter`] / [`FilterOp`]. Fase 7.1232.
824    pub filter: Vec<FilterOp>,
825    /// **Modo de mezcla** del nodo entero contra su backdrop (CSS
826    /// `mix-blend-mode`). `Some(bm)` ⇒ el subárbol del nodo se rasteriza en una
827    /// capa aislada (`scene.push_layer(bm, …)` alrededor del rect) y se mezcla
828    /// con el modo `bm` contra todo lo pintado antes en el stacking context.
829    /// `None` = source-over normal (la abrumadora mayoría). Ver [`View::blend`].
830    /// Fase 7.1237.
831    pub blend: Option<BlendMode>,
832    /// **RepaintBoundary** (Bloque 23 de `PLAN-NUCLEO-RETENIDO.md`): marca este
833    /// subárbol como candidato a **cachear su rasterización** entre frames. Si
834    /// el subárbol es paint-puro (sin painter/gpu/over/anim/ripple/gradiente/
835    /// imagen/hover adentro) y está en una posición "limpia" (sin transform/clip/
836    /// alpha ancestral), el runtime rasteriza su sub-escena una vez y la **reusa**
837    /// (append trasladado) mientras su contenido no cambie — evitando re-pintarlo.
838    /// `key` estable entre rebuilds. `None` = nodo normal (la abrumadora mayoría).
839    /// Ver [`View::repaint_boundary`].
840    pub repaint_boundary: Option<u64>,
841    /// **Memo** (Bloque 25): si está presente, el contenido de este nodo se
842    /// construye vía el `builder` diferido y se **reusa entre frames** mientras
843    /// `deps` no cambie (no se reconstruye el `View` ni se re-monta/re-layoutea).
844    /// El nodo debe tener un `Style` con tamaño definido. `None` = nodo normal.
845    /// Ver [`View::memo`].
846    pub memo: Option<MemoNode<Msg>>,
847    pub children: Vec<View<Msg>>,
848}
849
850impl<Msg: 'static> View<Msg> {
851    /// Transforma el `Msg` de **todo el árbol** vía `f`, devolviendo
852    /// `View<Msg2>`. Es la pieza que permite **embeber el `view` de un sub-app**
853    /// en un host (junto con [`crate::Handle::lift`] para sus efectos): el host
854    /// pinta `sub_view.map(Msg::Sub)` y los eventos del sub-árbol vuelven como
855    /// su propio `Msg`. Patrón estándar de anidado Elm. `f` se comparte (`Arc`)
856    /// entre todos los callbacks e hijos, así que debe ser `Send + Sync`.
857    pub fn map<Msg2, F>(self, f: F) -> View<Msg2>
858    where
859        Msg2: 'static,
860        F: Fn(Msg) -> Msg2 + Send + Sync + 'static,
861    {
862        self.map_shared(Arc::new(f))
863    }
864
865    fn map_shared<Msg2: 'static>(
866        self,
867        f: Arc<dyn Fn(Msg) -> Msg2 + Send + Sync>,
868    ) -> View<Msg2> {
869        let View {
870            style,
871            fill,
872            hover_fill,
873            radius,
874            corner_radii,
875            shadow,
876            fill_gradient,
877            border,
878            text,
879            image,
880            image_fit,
881            mask_image,
882            mask_placement,
883            mask_extra,
884            painter,
885            gpu_painter,
886            over_painter,
887            over_layer,
888            on_click,
889            on_click_at,
890            on_click_screen,
891            on_right_click,
892            on_right_click_at,
893            on_right_click_screen,
894            on_middle_click,
895            drag,
896            drag_at,
897            drag_velocity,
898            drag_payload,
899            on_drop,
900            drop_hover_fill,
901            clip,
902            clip_inset,
903            clip_ellipse,
904            clip_polygon,
905            clip_path_svg,
906            clip_ref_inset,
907            on_pointer_enter,
908            on_pointer_leave,
909            on_pointer_move_at,
910            on_scroll,
911            on_scroll_take,
912            on_scale,
913            on_rotate,
914            on_double_tap,
915            on_double_tap_at,
916            on_long_press,
917            on_long_press_at,
918            focusable,
919            text_select_key,
920            alpha,
921            anim,
922            animated_size,
923            semantics,
924            hero,
925            transform,
926            transform_rel,
927            transform_origin,
928            sticky,
929            tooltip,
930            cursor,
931            ripple,
932            layout_builder,
933            backdrop_blur,
934            filter,
935            blend,
936            repaint_boundary,
937            memo,
938            children,
939        } = self;
940        // Wrappers: cada callback que produce `Option<Msg>` se reenvía y su
941        // resultado se eleva con `f`. `f` se clona por callback (todos comparten
942        // el mismo `Arc`).
943        View {
944            // — campos agnósticos al Msg: pasan tal cual —
945            style,
946            fill,
947            hover_fill,
948            radius,
949            corner_radii,
950            shadow,
951            fill_gradient,
952            border,
953            text,
954            image,
955            image_fit,
956            mask_image,
957            mask_placement,
958            mask_extra,
959            painter,
960            gpu_painter,
961            over_painter,
962            over_layer,
963            drag_payload,
964            drop_hover_fill,
965            clip,
966            clip_inset,
967            clip_ellipse,
968            clip_polygon,
969            clip_path_svg,
970            clip_ref_inset,
971            focusable,
972            text_select_key,
973            alpha,
974            anim,
975            animated_size,
976            semantics,
977            hero,
978            transform,
979            transform_rel,
980            transform_origin,
981            sticky,
982            tooltip,
983            cursor,
984            ripple,
985            backdrop_blur,
986            filter,
987            blend,
988            repaint_boundary,
989            // El builder del memo produce `View<Msg>`; lo envolvemos para que
990            // produzca `View<Msg2>` mapeando su resultado con `f`.
991            memo: memo.map(|m| MemoNode {
992                key: m.key,
993                deps: m.deps,
994                builder: m.builder.map(|b| {
995                    let f = f.clone();
996                    Arc::new(move || b().map_shared(f.clone())) as MemoBuilderFn<Msg2>
997                }),
998            }),
999            // — Msg simples —
1000            on_click: on_click.map(|m| f(m)),
1001            on_right_click: on_right_click.map(|m| f(m)),
1002            on_middle_click: on_middle_click.map(|m| f(m)),
1003            on_pointer_enter: on_pointer_enter.map(|m| f(m)),
1004            on_pointer_leave: on_pointer_leave.map(|m| f(m)),
1005            on_double_tap: on_double_tap.map(|m| f(m)),
1006            on_long_press: on_long_press.map(|m| f(m)),
1007            // — ClickAtFn (lx, ly, w, h) —
1008            on_click_at: on_click_at.map(|h| {
1009                let f = f.clone();
1010                Arc::new(move |a, b, c, d| h(a, b, c, d).map(|m| f(m))) as ClickAtFn<Msg2>
1011            }),
1012            on_click_screen: on_click_screen.map(|h| {
1013                let f = f.clone();
1014                Arc::new(move |a, b, c, d| h(a, b, c, d).map(|m| f(m))) as ClickAtFn<Msg2>
1015            }),
1016            on_right_click_at: on_right_click_at.map(|h| {
1017                let f = f.clone();
1018                Arc::new(move |a, b, c, d| h(a, b, c, d).map(|m| f(m))) as ClickAtFn<Msg2>
1019            }),
1020            on_right_click_screen: on_right_click_screen.map(|h| {
1021                let f = f.clone();
1022                Arc::new(move |a, b, c, d| h(a, b, c, d).map(|m| f(m))) as ClickAtFn<Msg2>
1023            }),
1024            on_pointer_move_at: on_pointer_move_at.map(|h| {
1025                let f = f.clone();
1026                Arc::new(move |a, b, c, d| h(a, b, c, d).map(|m| f(m))) as ClickAtFn<Msg2>
1027            }),
1028            on_double_tap_at: on_double_tap_at.map(|h| {
1029                let f = f.clone();
1030                Arc::new(move |a, b, c, d| h(a, b, c, d).map(|m| f(m))) as ClickAtFn<Msg2>
1031            }),
1032            on_long_press_at: on_long_press_at.map(|h| {
1033                let f = f.clone();
1034                Arc::new(move |a, b, c, d| h(a, b, c, d).map(|m| f(m))) as ClickAtFn<Msg2>
1035            }),
1036            // — drag / scroll / gestos —
1037            drag: drag.map(|h| {
1038                let f = f.clone();
1039                Arc::new(move |p, dx, dy| h(p, dx, dy).map(|m| f(m))) as DragFn<Msg2>
1040            }),
1041            drag_at: drag_at.map(|h| {
1042                let f = f.clone();
1043                Arc::new(move |p, dx, dy, lx, ly| h(p, dx, dy, lx, ly).map(|m| f(m)))
1044                    as DragAtFn<Msg2>
1045            }),
1046            drag_velocity: drag_velocity.map(|h| {
1047                let f = f.clone();
1048                Arc::new(move |p, dx, dy, vx, vy| h(p, dx, dy, vx, vy).map(|m| f(m)))
1049                    as DragVelocityFn<Msg2>
1050            }),
1051            on_drop: on_drop.map(|h| {
1052                let f = f.clone();
1053                Arc::new(move |payload| h(payload).map(|m| f(m))) as DropFn<Msg2>
1054            }),
1055            on_scroll: on_scroll.map(|h| {
1056                let f = f.clone();
1057                Arc::new(move |dx, dy| h(dx, dy).map(|m| f(m))) as ScrollFn<Msg2>
1058            }),
1059            on_scroll_take: on_scroll_take.map(|h| {
1060                let f = f.clone();
1061                Arc::new(move |dx, dy| {
1062                    let (msg, rx, ry) = h(dx, dy);
1063                    (msg.map(|m| f(m)), rx, ry)
1064                }) as ScrollTakeFn<Msg2>
1065            }),
1066            on_scale: on_scale.map(|h| {
1067                let f = f.clone();
1068                Arc::new(move |ph, s, cx, cy| h(ph, s, cx, cy).map(|m| f(m))) as ScaleFn<Msg2>
1069            }),
1070            on_rotate: on_rotate.map(|h| {
1071                let f = f.clone();
1072                Arc::new(move |ph, r, cx, cy| h(ph, r, cx, cy).map(|m| f(m))) as RotateFn<Msg2>
1073            }),
1074            // — layout_builder produce un View<Msg>: recursa el map —
1075            layout_builder: layout_builder.map(|h| {
1076                let f = f.clone();
1077                Arc::new(move |c| h(c).map_shared(f.clone())) as LayoutBuilderFn<Msg2>
1078            }),
1079            // — hijos: recursión —
1080            children: children
1081                .into_iter()
1082                .map(|c| c.map_shared(f.clone()))
1083                .collect(),
1084        }
1085    }
1086}
1087
1088/// Versión "instalada" del árbol: cada nodo tiene su NodeId de taffy, color
1089/// y handler. Se mantiene en orden de inserción (recorrido pre-orden), así
1090/// el hit-test puede iterar al revés para honrar el orden de pintado.
1091///
1092/// `pub` (con campos `pub`) porque el runtime (llimphi-ui) lee el árbol
1093/// montado para hit-test y para la pasada GPU directa, pero vive en otro
1094/// crate. No se construye fuera de [`mount`].
1095pub struct Mounted<Msg> {
1096    pub root: NodeId,
1097    pub nodes: Vec<MountedNode<Msg>>,
1098    /// Contenido de texto por nodo-hoja, para que el runtime lo mida con
1099    /// parley durante `compute_with_measure` y taffy reserve el alto real
1100    /// del texto envuelto (varias líneas) en vez de una sola. Sin esto un
1101    /// párrafo que envuelve a N líneas se aplastaría en la altura de una
1102    /// (el bug clásico de "textos aplastados"). Sólo se pueblan hojas con
1103    /// texto uniforme (sin `runs` multicolor, que el caller dimensiona).
1104    pub text_measures: HashMap<NodeId, TextMeasure>,
1105}
1106
1107/// Datos de un nodo-hoja de texto necesarios para medirlo (shaping +
1108/// line-break) sin volver a tocar el `View`. Lo consume el runtime en la
1109/// función de medición que le pasa a [`LayoutTree::compute_with_measure`].
1110#[derive(Clone)]
1111pub struct TextMeasure {
1112    pub content: String,
1113    pub size_px: f32,
1114    pub alignment: llimphi_text::Alignment,
1115    pub italic: bool,
1116    pub font_family: Option<String>,
1117    pub line_height: f32,
1118    pub weight: f32,
1119    pub max_lines: Option<usize>,
1120    pub ellipsis: bool,
1121    /// Idem [`TextSpec::underline`]. Se replica en la medida porque parley
1122    /// no cambia de ancho con decoración (no toca el shaping); pero la clave
1123    /// del caché de shaping sí cambia, y queremos que medida y pintado
1124    /// peguen la misma entrada del caché.
1125    pub underline: bool,
1126    /// Idem [`TextSpec::strikethrough`]. Mismo razonamiento que `underline`.
1127    pub strikethrough: bool,
1128    /// Idem [`TextSpec::spans`]. La medida usa el mismo
1129    /// `Typesetter::layout_spans` que el pintado, así taffy reserva el alto
1130    /// real considerando overrides de `size_px` por span (un `<h1>` inline
1131    /// dentro de un párrafo agranda esa línea). `None`/`vacío` = medir con
1132    /// `layout_clamped` (camino uniforme).
1133    pub spans: Option<Vec<llimphi_text::TextSpan>>,
1134    /// Idem [`TextSpec::letter_spacing`]. Entra en la medida porque cambia el
1135    /// ancho del shaping (y la clave del caché).
1136    pub letter_spacing: f32,
1137    /// Idem [`TextSpec::word_spacing`]. Mismo razonamiento que `letter_spacing`.
1138    pub word_spacing: f32,
1139    /// Idem [`TextSpec::no_wrap`]. Entra en la medida porque cambia el ancho
1140    /// reservado: con `no_wrap` el texto se mide en una sola línea (ancho
1141    /// completo) en vez de envolver al `available`.
1142    pub no_wrap: bool,
1143    /// Idem [`TextSpec::overflow_wrap`]. Entra en la medida porque parte la
1144    /// palabra larga: con el flag, el ancho mínimo del bloque deja de estar
1145    /// fijado por el token más ancho.
1146    pub overflow_wrap: bool,
1147    /// Idem [`TextSpec::font_features`]. Entra en la medida porque ligaduras/
1148    /// kerning cambian el ancho del shaping (y la clave del caché).
1149    pub font_features: String,
1150    /// Idem [`TextSpec::font_variations`]. Mismo razonamiento que `font_features`.
1151    pub font_variations: String,
1152    /// Idem [`TextSpec::font_width`]. Entra en la medida porque la face
1153    /// condensada/expandida cambia el ancho del shaping (y la clave del caché).
1154    pub font_width: f32,
1155}
1156
1157/// Cómo encajar una imagen en el rect del nodo (CSS `object-fit` /
1158/// Flutter `BoxFit`). El runtime calcula la escala y el origen
1159/// correspondientes a esta política y siempre recorta al
1160/// `node_rrect` del nodo, así el clip respeta `radius` /
1161/// `corner_radii`.
1162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1163pub enum ImageFit {
1164    /// Preservar aspect ratio, **caber** dentro del rect (escala =
1165    /// `min(sx, sy)`). Deja banda en el eje menos restrictivo.
1166    /// CSS `object-fit: contain` / Flutter `BoxFit.contain`. **Default
1167    /// histórico** — lo que hacía `View::image()` antes del Bloque 12.
1168    Contain,
1169    /// Preservar aspect ratio, **cubrir** todo el rect (escala =
1170    /// `max(sx, sy)`). Recorta el sobrante en el eje menos
1171    /// restrictivo (el clip al `node_rrect` lo absorbe). CSS
1172    /// `object-fit: cover` / Flutter `BoxFit.cover` — ideal para
1173    /// avatares y hero images.
1174    Cover,
1175    /// Estirar la imagen para ocupar el rect, **sin** preservar
1176    /// aspect ratio (`sx`/`sy` independientes). CSS `object-fit:
1177    /// fill` / Flutter `BoxFit.fill`.
1178    Fill,
1179    /// **No** escalar la imagen — pintarla a su tamaño original,
1180    /// centrada en el rect. Si la imagen excede el rect, el clip al
1181    /// `node_rrect` la recorta. CSS `object-fit: none` / Flutter
1182    /// `BoxFit.none`.
1183    None,
1184}
1185
1186/// Longitud de un eje de [`MaskSize`]/posición de máscara, **sin resolver** —
1187/// el paint la resuelve contra el rect del nodo. Neutral respecto de CSS: el
1188/// frontend (p. ej. puriy) traduce `mask-size`/`mask-position` a esto. Fase
1189/// 7.1227.
1190#[derive(Debug, Clone, Copy, PartialEq)]
1191pub enum MaskLen {
1192    /// Tamaño intrínseco de la imagen (en size) / offset 0 (en position).
1193    Auto,
1194    /// Longitud absoluta en px.
1195    Px(f32),
1196    /// Porcentaje: en size, del lado correspondiente del rect; en position,
1197    /// alineación CSS (el `p%` de la máscara cae sobre el `p%` del rect).
1198    Pct(f32),
1199}
1200
1201/// `mask-size` neutral (espejo de `BackgroundSize`). Ver [`MaskPlacement`].
1202/// Fase 7.1227.
1203#[derive(Debug, Clone, Copy, PartialEq)]
1204pub enum MaskSize {
1205    /// Tamaño intrínseco de la imagen-máscara.
1206    Auto,
1207    /// Escalar preservando aspecto hasta **cubrir** el rect.
1208    Cover,
1209    /// Escalar preservando aspecto hasta **caber** en el rect.
1210    Contain,
1211    /// Tamaño explícito por eje (`Auto` en un eje = derivar por aspecto).
1212    Explicit { x: MaskLen, y: MaskLen },
1213}
1214
1215/// Modo de una máscara (CSS `mask-mode`). Decide qué canal del píxel-máscara
1216/// modula el alpha del contenido. Fase 7.1228.
1217#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1218pub enum MaskMode {
1219    /// La **luminancia** del píxel multiplica el alpha (negro = oculto, blanco =
1220    /// visible). Lo usa CSS para máscaras SVG `<mask>`. Es el default del
1221    /// compositor cuando no hay `MaskPlacement` (camino estirado, Fase 7.1226).
1222    #[default]
1223    Luminance,
1224    /// El **canal alpha** del píxel modula el alpha (transparente = oculto). Es
1225    /// el default CSS para imágenes raster (`mask-mode: match-source`). Se pinta
1226    /// con `Compose::DestIn` en vez de la capa de luminancia.
1227    Alpha,
1228}
1229
1230/// Operador de combinación entre capas de máscara (CSS `mask-composite`). Mapea
1231/// a un `Compose` Porter-Duff de vello cuando una capa extra se compone sobre
1232/// las de abajo. Fase 7.1231.
1233#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1234pub enum MaskCompose {
1235    /// La capa se **suma** sobre las de abajo (source-over). Default CSS.
1236    #[default]
1237    Add,
1238    /// La capa **resta** (source-out: la fuente donde NO solapa el destino).
1239    Subtract,
1240    /// **Intersección** (source-in: la fuente donde solapa el destino).
1241    Intersect,
1242    /// **Exclusión** (xor: las regiones no solapadas de ambas).
1243    Exclude,
1244}
1245
1246/// Encaje y modo de una **máscara** (CSS `mask-size` + `mask-position` +
1247/// `mask-repeat` + `mask-mode`), resuelto contra el rect del nodo en el paint,
1248/// con la misma aritmética que `background-image`. En el [`MountedNode`] viaja
1249/// como `Option`: `None` = estirar la máscara al border-box en modo luminancia
1250/// (comportamiento de la Fase 7.1226). Fase 7.1227 (encaje), 7.1228 (modo).
1251#[derive(Debug, Clone, Copy, PartialEq)]
1252pub struct MaskPlacement {
1253    /// Tamaño del tile.
1254    pub size: MaskSize,
1255    /// Offset/alineación horizontal del primer tile.
1256    pub pos_x: MaskLen,
1257    /// Offset/alineación vertical del primer tile.
1258    pub pos_y: MaskLen,
1259    /// Tilear en X (`mask-repeat` cubre el eje horizontal).
1260    pub repeat_x: bool,
1261    /// Tilear en Y.
1262    pub repeat_y: bool,
1263    /// Canal que modula el alpha (luminancia vs alpha). Fase 7.1228.
1264    pub mode: MaskMode,
1265    /// Insets `[top, right, bottom, left]` px del border-box a la caja de
1266    /// `mask-clip`: el efecto de la máscara se **recorta** a esa caja. `None` =
1267    /// border-box. Fase 7.1230.
1268    pub clip_inset: Option<[f32; 4]>,
1269    /// Insets `[top, right, bottom, left]` px del border-box a la caja de
1270    /// `mask-origin`: size/position/tiling se resuelven contra esa caja. `None`
1271    /// = border-box. Fase 7.1230.
1272    pub origin_inset: Option<[f32; 4]>,
1273}
1274
1275impl Default for ImageFit {
1276    fn default() -> Self {
1277        ImageFit::Contain
1278    }
1279}
1280
1281/// Forma del puntero del mouse. Subconjunto práctico, llimphi-native (el
1282/// compositor no depende de winit). El runtime (`llimphi-ui`) mapea 1:1 a
1283/// `winit::window::CursorIcon`. Nombres alineados con CSS/winit.
1284#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1285pub enum Cursor {
1286    /// Flecha por defecto.
1287    Default,
1288    /// Manito — sobre algo clickeable (links, botones).
1289    Pointer,
1290    /// I-beam — sobre texto editable/seleccionable.
1291    Text,
1292    /// Cruz — selección precisa (canvas, picker de color).
1293    Crosshair,
1294    /// Cuatro flechas — mover un objeto.
1295    Move,
1296    /// Mano abierta — agarrable (antes de arrastrar).
1297    Grab,
1298    /// Mano cerrada — arrastrando.
1299    Grabbing,
1300    /// Prohibido — drop no permitido / acción inválida.
1301    NotAllowed,
1302    /// Reloj/espera — operación bloqueante.
1303    Wait,
1304    /// Progreso — ocupado pero la UI responde.
1305    Progress,
1306    /// Interrogación — ayuda contextual.
1307    Help,
1308    /// Resize horizontal (columna / divisor vertical).
1309    ColResize,
1310    /// Resize vertical (fila / divisor horizontal).
1311    RowResize,
1312    /// Resize este-oeste.
1313    EwResize,
1314    /// Resize norte-sur.
1315    NsResize,
1316    /// Resize diagonal ↗↙.
1317    NeswResize,
1318    /// Resize diagonal ↖↘.
1319    NwseResize,
1320    /// Lupa + (zoom in).
1321    ZoomIn,
1322    /// Lupa − (zoom out).
1323    ZoomOut,
1324}
1325
1326#[derive(Clone)]
1327pub struct MountedNode<Msg> {
1328    pub id: NodeId,
1329    pub fill: Option<Color>,
1330    pub hover_fill: Option<Color>,
1331    pub radius: f64,
1332    pub corner_radii: Option<RoundedRectRadii>,
1333    pub shadow: Option<Shadow>,
1334    pub fill_gradient: Option<Gradient>,
1335    pub border: Option<Border>,
1336    pub text: Option<TextSpec>,
1337    pub image: Option<Image>,
1338    /// Política de encaje de [`Self::image`] (ver [`ImageFit`]). `None`
1339    /// = `Contain`.
1340    pub image_fit: Option<ImageFit>,
1341    /// Máscara de luminancia del subárbol (CSS `mask-image`). Ver
1342    /// [`View::mask_image`]. El paint aísla el subárbol y aplica la luminancia
1343    /// de esta imagen como alpha. `None` = sin máscara.
1344    pub mask_image: Option<Image>,
1345    /// Encaje de [`Self::mask_image`] (size/position/repeat). `None` = estirar
1346    /// al border-box. Ver [`MaskPlacement`]. Fase 7.1227.
1347    pub mask_placement: Option<MaskPlacement>,
1348    /// Capas de máscara adicionales `(imagen, operador)` (ver [`View::mask_extra`]).
1349    /// Comparten el `mask_placement` con la capa 0. Fase 7.1231.
1350    pub mask_extra: Vec<(Image, MaskCompose)>,
1351    pub painter: Option<PaintFn>,
1352    pub gpu_painter: Option<GpuPaintFn>,
1353    pub over_painter: Option<PaintFn>,
1354    /// Subárbol over (ver [`View::over`]): este nodo y sus descendientes
1355    /// pintan en la pasada vello final, después del pase GPU.
1356    pub over_layer: bool,
1357    pub on_click: Option<Msg>,
1358    pub on_click_at: Option<ClickAtFn<Msg>>,
1359    /// Gemelo de izquierda de [`Self::on_right_click_screen`]: click izquierdo con
1360    /// coords **absolutas de ventana**, para anclar un popup (select/dropdown).
1361    pub on_click_screen: Option<ClickAtFn<Msg>>,
1362    pub on_right_click: Option<Msg>,
1363    pub on_right_click_at: Option<ClickAtFn<Msg>>,
1364    pub on_right_click_screen: Option<ClickAtFn<Msg>>,
1365    pub on_middle_click: Option<Msg>,
1366    pub drag: Option<DragFn<Msg>>,
1367    pub drag_at: Option<DragAtFn<Msg>>,
1368    pub drag_velocity: Option<DragVelocityFn<Msg>>,
1369    pub drag_payload: Option<u64>,
1370    pub on_drop: Option<DropFn<Msg>>,
1371    pub drop_hover_fill: Option<Color>,
1372    pub clip: bool,
1373    pub clip_inset: Option<[f32; 4]>,
1374    pub clip_ellipse: Option<[f32; 14]>,
1375    pub clip_polygon: Option<(bool, Vec<[f32; 4]>)>,
1376    pub clip_path_svg: Option<(bool, String)>,
1377    pub clip_ref_inset: Option<[f32; 4]>,
1378    pub on_pointer_enter: Option<Msg>,
1379    pub on_pointer_leave: Option<Msg>,
1380    pub on_pointer_move_at: Option<ClickAtFn<Msg>>,
1381    pub on_scroll: Option<ScrollFn<Msg>>,
1382    /// Handler de scroll con reparto parcial. Ver [`ScrollTakeFn`].
1383    pub on_scroll_take: Option<ScrollTakeFn<Msg>>,
1384    /// Handler de gesto de escala (pinch-to-zoom) de este nodo. Ver
1385    /// [`View::on_scale`] y [`ScaleFn`].
1386    pub on_scale: Option<ScaleFn<Msg>>,
1387    /// Handler de gesto de rotación (trackpad) de este nodo. Ver
1388    /// [`View::on_rotate`] y [`RotateFn`].
1389    pub on_rotate: Option<RotateFn<Msg>>,
1390    /// Handlers de doble-tap (ver [`View::on_double_tap`]).
1391    pub on_double_tap: Option<Msg>,
1392    pub on_double_tap_at: Option<ClickAtFn<Msg>>,
1393    /// Handlers de long-press (ver [`View::on_long_press`]).
1394    pub on_long_press: Option<Msg>,
1395    pub on_long_press_at: Option<ClickAtFn<Msg>>,
1396    pub focusable: Option<u64>,
1397    /// Key estable de selección de texto (ver [`View::selectable`]).
1398    pub text_select_key: Option<u64>,
1399    pub alpha: Option<f32>,
1400    pub anim: Option<Anim>,
1401    /// Animación implícita de tamaño (ver [`View::animated_size`]). El
1402    /// runtime ya parchó `style.size` antes del layout — este campo se
1403    /// guarda principalmente para inspección/tests.
1404    pub animated_size: Option<SizeAnim>,
1405    /// Semántica accesible del nodo (ver [`View::semantics`]). El runtime la
1406    /// lee en cada paint para reconstruir el árbol AccessKit del frame.
1407    pub semantics: Option<SemanticsSpec>,
1408    /// Marca de hero shared-element (ver [`View::hero`]). El runtime lo lee
1409    /// en [`HeroRegistry::reconcile`] para enlazar identidad entre frames y
1410    /// escribir `transform` con la afín "fly" cuando el rect cambia.
1411    pub hero: Option<Hero>,
1412    /// Subescena a pintar **DEBAJO** del contenido de este nodo, en su
1413    /// posición z (y dentro del clip/transform de sus ancestros). La escribe
1414    /// el runtime — [`AnimRegistry::prepare_underlays`]: el fantasma del
1415    /// contenido viejo de un `switch` con `exit_under` — no el `View`. El
1416    /// paint la apendea componiendo la afín acumulada de los ancestros,
1417    /// ANTES del transform/alpha propios del nodo (que pertenecen al
1418    /// contenido NUEVO).
1419    pub underlay: Option<vello::Scene>,
1420    /// Transformación afín 2D del nodo (alrededor del centro de su rect).
1421    /// Ver [`View::transform`]. `paint` la compone con la del padre.
1422    pub transform: Option<Affine>,
1423    /// Traslación relativa al tamaño del nodo (fracciones de su rect). Ver
1424    /// [`View::transform_rel`]. `paint`/`hit_test` la resuelven contra el rect.
1425    pub transform_rel: Option<(f64, f64)>,
1426    /// Pivote de `transform` (CSS `transform-origin`). `None` ⇒ centro. Ver
1427    /// [`TransformPivot`] / [`View::transform_origin`].
1428    pub transform_origin: Option<TransformPivot>,
1429    /// `position: sticky`: insets `[top, right, bottom, left]` px relativos al
1430    /// scrollport. Ver [`View::sticky`]. `paint` lo resuelve con los rects
1431    /// computados (el scroll ya está en el layout). `None` ⇒ no-sticky.
1432    pub sticky: Option<[Option<f32>; 4]>,
1433    /// Texto de tooltip de este nodo (ver [`View::tooltip`]). El consumidor lo
1434    /// lee tras un hit-test de hover para pintar el rótulo flotante.
1435    pub tooltip: Option<String>,
1436    /// Forma del puntero sobre este nodo (ver [`View::cursor`]). El runtime la
1437    /// resuelve heredando del ancestro más cercano que la declare.
1438    pub cursor: Option<Cursor>,
1439    /// Ripple/InkWell de este nodo (ver [`View::ripple`]). El runtime lo
1440    /// dispara en el press y lo pinta vía [`RippleRegistry`].
1441    pub ripple: Option<Ripple>,
1442    /// `true` si este nodo era un [`View::layout_builder`] (constructor diferido)
1443    /// al montarse. El runtime lo usa tras la primera pasada de layout para leer
1444    /// el rect del slot (vía [`collect_builder_constraints`]) e invocar la
1445    /// closure. Tras expandirse, el nodo final ya es normal (`false`).
1446    pub is_layout_builder: bool,
1447    /// **Backdrop blur** (CSS `backdrop-filter: blur(N)` / Flutter
1448    /// `BackdropFilter`). Sigma del Gauss en pixels; el runtime aplica una
1449    /// pasada separable (H+V) sobre la intermediate restringida al rect del
1450    /// nodo, **antes** de pintar el subárbol del nodo. El subárbol se compone
1451    /// sobre el backdrop ya borroso vía un buffer secundario. `None` = sin
1452    /// blur (la abrumadora mayoría). Limitación v1: el nodo no debe estar
1453    /// dentro de un ancestro con clip/alpha (los subárboles separados pintan
1454    /// fuera de esas capas — documentado en `PARIDAD-FLUTTER.md` Bloque 11).
1455    pub backdrop_blur: Option<f32>,
1456    /// Filtros CSS (`filter: …`) sobre el propio subárbol (ver [`View::filter`]
1457    /// / [`FilterOp`]). El runtime los recolecta con [`collect_filters`] y los
1458    /// aplica como post-pasada GPU sobre la intermediate, restringidos al rect
1459    /// del nodo, **después** de la rasterización. Vacío = sin filtro. Fase
1460    /// 7.1232.
1461    pub filter: Vec<FilterOp>,
1462    /// Modo de mezcla del nodo entero contra su backdrop (CSS `mix-blend-mode`).
1463    /// Ver [`View::blend`] / [`MountedNode`]. `paint_range` abre una capa de
1464    /// blend (`push_layer(bm, …)`) alrededor del rect del nodo que envuelve
1465    /// fill + contenido + hijos y se cierra al fin del subárbol, mezclando el
1466    /// resultado contra lo ya pintado. `None` = source-over. Fase 7.1237.
1467    pub blend: Option<BlendMode>,
1468    /// Key de [`View::repaint_boundary`] si este nodo abre un límite de
1469    /// repintado cacheable. `None` = normal. Lo consume `paint_cached`.
1470    pub repaint_boundary: Option<u64>,
1471    /// Key de [`View::memo`] si este nodo es un marcador de subárbol memoizado
1472    /// (Bloque 25). El runtime lo splica tras el layout. `None` = normal.
1473    pub memo_ref: Option<u64>,
1474    /// Índice (exclusivo) del fin del subárbol en `Mounted::nodes`. Los
1475    /// descendientes ocupan `[idx + 1, subtree_end)`. Hace de "barrera" en
1476    /// paint/hit_test para `pop_layer` y para saltar subárboles enteros.
1477    pub subtree_end: usize,
1478}