refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel

Re-sincroniza las fuentes desde el monorepo (estaba en vello 0.5/wgpu 24 y con la
estructura vieja de eventloop) y suma el 3D:

- bump del workspace a vello 0.7 / wgpu 27 / parley 0.6, + accesskit 0.24 /
  accesskit_winit 0.33 / vello_hybrid 0.0.9.
- nuevos crates: llimphi-3d (voxels ray-march + mallas en un depth compartido,
  montable dentro de un View 2D vía set_viewport+scissor) y llimphi-voxel
  (world-gen, personajes, director de escenas) + shared/foreign-vox (puente .vox).
- README: sección "Not just 2D — a 3D voxel engine" + GIF (docs/llimphi_voxel.gif).
- excluido modules/allichay (arrastra deps fuera del alcance del front-door).
- cargo check --workspace: verde.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-06-18 14:40:00 +00:00
parent e74800d9da
commit ccab39f140
202 changed files with 44034 additions and 1811 deletions
File diff suppressed because it is too large Load Diff
+273
View File
@@ -0,0 +1,273 @@
//! **Hero / shared-element transitions** — un mismo nodo lógico (key estable)
//! que aparece en posiciones distintas entre frames "vuela" del rect anterior
//! al actual en vez de saltar. Es el Hero de Flutter auténtico.
//!
//! Modelo:
//! - El caller marca un nodo con [`View::hero(key, duration)`](crate::View::hero).
//! `key` enlaza "el mismo nodo lógico" entre dos `view()` distintos (entre
//! rutas, paneles, layouts) — dos nodos con la misma `key` en frames distintos
//! son la misma identidad para el runtime.
//! - El runtime mantiene una instancia de [`HeroRegistry`] entre frames y llama
//! [`HeroRegistry::reconcile`] DESPUÉS de `compute` y ANTES de `paint`. Por
//! cada nodo hero:
//! - Lee su rect absoluto del [`ComputedLayout`].
//! - Si en el frame anterior la misma `key` vivió en un rect distinto,
//! arranca un tween: durante `duration`, escribe en `node.transform` una
//! afín que "lleva visualmente" el nodo del rect actual al rect anterior y
//! converge a `IDENTITY`. El nodo se ve VOLAR del rect anterior al actual.
//! - Mientras el tween esté vivo, devuelve `true` y el runtime pide otro
//! frame (ticker autodetenido).
//! - Al asentarse, deja `node.transform = None`: cero costo de transform
//! residual en frames posteriores.
//!
//! No depende de [`crate::AnimRegistry`] — el wiring es independiente; sólo
//! reusa el campo `transform` del [`MountedNode`](crate::MountedNode), que el
//! `paint` ya respeta como cualquier otro afín.
//!
//! ## Reglas de uso
//!
//! - `key` debe ser estable y **única** entre los nodos hero presentes en un
//! mismo frame. Dos hero con la misma key en el mismo árbol generan
//! ambigüedad; el runtime se queda con la última que recorra.
//! - El rect "anterior" es el del frame anterior — no funciona como
//! shared-element entre dos *vistas montadas a la vez* (eso requeriría dos
//! rect simultáneos por key). Funciona entre transiciones de rutas.
use std::collections::HashMap;
use std::time::{Duration, Instant};
use llimphi_layout::{ComputedLayout, Rect};
use vello::kurbo::Affine;
/// Declara un nodo como **hero**: la `key` enlaza la identidad entre frames; si
/// el rect cambia, el runtime anima la transición.
#[derive(Clone, Copy, Debug)]
pub struct Hero {
pub key: u64,
pub duration: Duration,
/// Easing aplicado a `t ∈ [0,1]`. Por defecto, los setters de [`View`]
/// usan un ease-out cúbico (igual que las animaciones implícitas).
pub easing: fn(f32) -> f32,
}
/// Registro de heroes, vivo entre frames. Guarda el último rect por `key` para
/// detectar el delta y un tween activo si está animando.
#[derive(Default)]
pub struct HeroRegistry {
/// Último rect donde se pintó un nodo con esta `key`. Se actualiza en cada
/// `reconcile`. Es contra esto que detectamos el cambio que dispara el
/// tween.
last: HashMap<u64, Rect>,
/// Tweens en curso. Cada uno conoce su `from_rect`, el reloj y el easing.
/// Una key con tween activo NO arranca uno nuevo si vuelve a moverse —
/// reusamos el `from_rect` original para que la trayectoria sea continua
/// (si el target cambia a mitad, vuela hacia el nuevo destino, no cambia
/// el origen).
tweens: HashMap<u64, Tween>,
}
struct Tween {
from_rect: Rect,
start: Instant,
duration: Duration,
easing: fn(f32) -> f32,
}
impl HeroRegistry {
pub fn new() -> Self {
Self::default()
}
/// Reconcilia heroes con el árbol montado. Para cada nodo con [`Hero`]:
/// - Si el rect cambió respecto del frame anterior, arranca tween.
/// - Si hay tween activo y vivo, escribe `node.transform` con la afín
/// interpolada (cur → from).
/// - Cuando el tween termina, lo limpia y deja `node.transform = None`.
///
/// Llamar DESPUÉS de `compute` y ANTES de `paint`. Devuelve `true` si
/// algún tween sigue en curso → el runtime pide otro frame.
pub fn reconcile<Msg>(
&mut self,
mounted: &mut crate::Mounted<Msg>,
computed: &ComputedLayout,
now: Instant,
) -> bool {
let mut animating = false;
let mut seen: Vec<u64> = Vec::new();
for node in &mut mounted.nodes {
let Some(hero) = node.hero else { continue };
let Some(cur) = computed.get(node.id) else { continue };
seen.push(hero.key);
// ¿Cambió el rect respecto del último frame? Arrancar tween (si no
// hay uno activo; si lo hay, no re-resetamos el origen).
if let Some(last) = self.last.get(&hero.key).copied() {
if last != cur && !self.tweens.contains_key(&hero.key) {
self.tweens.insert(
hero.key,
Tween {
from_rect: last,
start: now,
duration: hero.duration,
easing: hero.easing,
},
);
}
}
// Aplicar tween si está vivo. Calcula la afín que mapea `cur` al
// `from_rect` y la interpola hacia identidad a medida que `t` crece.
if let Some(tw) = self.tweens.get(&hero.key) {
let elapsed = now.saturating_duration_since(tw.start).as_secs_f32();
let raw = (elapsed / tw.duration.as_secs_f32().max(1e-6)).clamp(0.0, 1.0);
if raw >= 1.0 {
// Aterrizó: dejamos el nodo sin transform y limpiamos.
node.transform = None;
self.tweens.remove(&hero.key);
} else {
let t = (tw.easing)(raw);
let back = back_transform(cur, tw.from_rect);
let xf = lerp_affine(back, Affine::IDENTITY, t);
node.transform = Some(xf);
animating = true;
}
}
self.last.insert(hero.key, cur);
}
// Las keys que no aparecieron este frame se descartan (un hero que se
// va deja de recordarse; si vuelve, su rect "anterior" será el nuevo
// primero — no anima desde el último que tuvo hace varios frames).
if self.last.len() != seen.len() {
self.last.retain(|k, _| seen.contains(k));
self.tweens.retain(|k, _| seen.contains(k));
}
animating
}
}
/// Afín local que, aplicada con [`View::transform`]'s convención (alrededor
/// del centro del rect actual), mapea visualmente cada punto del `cur_rect`
/// al punto correspondiente del `from_rect`. Es la base de un "fly":
/// el nodo se pinta en `cur` pero con esta xf VOLVIÓ a `from` —
/// interpolando hacia identidad, "vuela" de `from` a `cur`.
fn back_transform(cur: Rect, from: Rect) -> Affine {
// El compositor aplica xf como `T(centro_cur) · xf_local · T(-centro_cur)`,
// así que xf_local debe ser `scale + translate` que mapea:
// esquina superior izquierda de cur → esquina superior izquierda de from.
//
// Si scale = (from.w/cur.w, from.h/cur.h) y t = (cx_from - cx_cur,
// cy_from - cy_cur), entonces `T(t) · S` cumple esa propiedad (despejo en
// los comentarios del módulo).
let sx = (from.w as f64) / (cur.w as f64).max(1e-6);
let sy = (from.h as f64) / (cur.h as f64).max(1e-6);
let cx_cur = (cur.x + cur.w * 0.5) as f64;
let cy_cur = (cur.y + cur.h * 0.5) as f64;
let cx_from = (from.x + from.w * 0.5) as f64;
let cy_from = (from.y + from.h * 0.5) as f64;
Affine::translate((cx_from - cx_cur, cy_from - cy_cur)) * Affine::scale_non_uniform(sx, sy)
}
/// Lerp componente-a-componente de las 6 coefs del afín. Idéntica al helper
/// privado de [`crate::anim`] — vive separada para mantener el módulo `hero`
/// auto-contenido (sin acoplar a Anim).
fn lerp_affine(a: Affine, b: Affine, t: f32) -> Affine {
let p = a.as_coeffs();
let q = b.as_coeffs();
let ft = t as f64;
Affine::new([
p[0] + (q[0] - p[0]) * ft,
p[1] + (q[1] - p[1]) * ft,
p[2] + (q[2] - p[2]) * ft,
p[3] + (q[3] - p[3]) * ft,
p[4] + (q[4] - p[4]) * ft,
p[5] + (q[5] - p[5]) * ft,
])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{mount, View};
use llimphi_layout::{LayoutTree, Style};
use llimphi_layout::taffy::prelude::length;
use llimphi_layout::taffy::Size;
/// Monta un único nodo hero con su Style + key=1 + dur=200ms. Devuelve
/// `Mounted` y el `ComputedLayout` ya resuelto contra un viewport de
/// 1000×1000 — los rects salen del propio Style.
fn one(x: f32, y: f32, w: f32, h: f32) -> (crate::Mounted<()>, ComputedLayout) {
let v = View::<()>::new(Style {
size: Size { width: length(w), height: length(h) },
inset: llimphi_layout::taffy::Rect {
left: length(x),
top: length(y),
right: llimphi_layout::taffy::prelude::auto(),
bottom: llimphi_layout::taffy::prelude::auto(),
},
position: llimphi_layout::taffy::Position::Absolute,
..Default::default()
})
.hero(1, Duration::from_millis(200));
let mut layout = LayoutTree::new();
let mounted = mount(&mut layout, v);
let computed = layout
.compute(mounted.root, (1000.0_f32, 1000.0_f32))
.expect("layout");
(mounted, computed)
}
#[test]
fn primera_aparicion_no_anima() {
let mut reg = HeroRegistry::new();
let (mut m, c) = one(10.0, 10.0, 50.0, 50.0);
let animating = reg.reconcile(&mut m, &c, Instant::now());
assert!(!animating, "primera aparición no debe animar");
assert!(m.nodes[0].transform.is_none(), "sin xf en primer frame");
}
#[test]
fn cambio_de_rect_arranca_tween_y_aplica_xf() {
let mut reg = HeroRegistry::new();
let t0 = Instant::now();
// Frame 1: rect (10, 10, 50, 50).
let (mut m, c) = one(10.0, 10.0, 50.0, 50.0);
reg.reconcile(&mut m, &c, t0);
// Frame 2: el nodo ahora vive en (200, 200, 100, 100) → arranca tween.
let (mut m, c) = one(200.0, 200.0, 100.0, 100.0);
let animating = reg.reconcile(&mut m, &c, t0 + Duration::from_millis(50));
assert!(animating, "cambio de rect → tween");
let xf = m.nodes[0].transform.expect("xf");
// A 50ms en una anim de 200ms, raw ≈ 0.25; con ease-out cúbico t > 0.25.
// La afín NO debe ser identidad (algún coef se ve).
let c = xf.as_coeffs();
assert!(c[0] != 1.0 || c[3] != 1.0 || c[4] != 0.0 || c[5] != 0.0,
"xf no debe ser identidad a mitad del tween: {:?}", c);
}
#[test]
fn al_terminar_limpia_la_xf() {
let mut reg = HeroRegistry::new();
let t0 = Instant::now();
let (mut m, c) = one(10.0, 10.0, 50.0, 50.0);
reg.reconcile(&mut m, &c, t0);
let (mut m, c) = one(200.0, 200.0, 100.0, 100.0);
reg.reconcile(&mut m, &c, t0 + Duration::from_millis(10));
// Pasada la duración: el tween se descarta y deja el nodo sin xf.
let (mut m, c) = one(200.0, 200.0, 100.0, 100.0);
let animating = reg.reconcile(&mut m, &c, t0 + Duration::from_millis(500));
assert!(!animating);
assert!(m.nodes[0].transform.is_none());
}
#[test]
fn back_transform_es_identidad_si_los_rects_coinciden() {
let r = Rect { x: 50.0, y: 50.0, w: 100.0, h: 100.0 };
let xf = back_transform(r, r);
let c = xf.as_coeffs();
assert!((c[0] - 1.0).abs() < 1e-9);
assert!((c[3] - 1.0).abs() < 1e-9);
assert!(c[4].abs() < 1e-9);
assert!(c[5].abs() < 1e-9);
}
}
+233
View File
@@ -0,0 +1,233 @@
//! **LayoutBuilder** — el 4º seam de PARIDAD-FLUTTER: construir un subárbol
//! sensible al **tamaño del slot** del nodo (no de la ventana — para eso
//! alcanza `on_resize` + el Model). Flutter `LayoutBuilder`.
//!
//! El modelo de Llimphi corre `view → mount → compute → paint`: el `View` se
//! arma ANTES de conocer el layout, así que "construir distinto según el espacio
//! disponible" exige diferir. La solución, sin tocar `mount`/`paint`, es una
//! **resolución en dos pasadas** orquestada por el runtime:
//!
//! 1. Montar el árbol tal cual ([`crate::View::layout_builder`] queda como
//! **hoja** — no tiene `children` estáticos) y computar el layout. Ahora cada
//! builder tiene su rect resuelto por su `Style`/contexto flex.
//! 2. [`collect_builder_constraints`] lee esos rects (en pre-orden), se pide un
//! `view()` fresco y [`expand_layout_builders`] invoca cada closure con sus
//! [`crate::Constraints`] para producir el subárbol real. Ese árbol expandido
//! se monta y pinta normalmente.
//!
//! [`has_layout_builder`] hace que todo esto sea **coste cero** cuando ningún
//! nodo usa el builder (el caso de la abrumadora mayoría de frames): es un
//! simple walk que corta el camino de dos pasadas.
//!
//! **Correspondencia de orden.** `collect_builder_constraints` recorre
//! `Mounted::nodes` (pre-orden, padre antes que hijos — el orden en que `mount`
//! los pushea) filtrando `is_layout_builder`; `expand_layout_builders` recorre
//! el `View` fresco en el MISMO pre-orden asignando un índice por builder. Como
//! ambos árboles salen del mismo `view(model)` determinista, el i-ésimo builder
//! de uno corresponde al i-ésimo del otro — por eso alcanza con un `Vec`
//! ordenado, sin keys.
//!
//! **Límite v1**: sin anidamiento. Un builder cuyo subárbol producido contiene
//! otro `layout_builder` no resuelve el interno (no existía en la pasada 1):
//! queda como hoja. El anidamiento requeriría iterar la resolución; se difiere.
use crate::{Constraints, ComputedLayout, Mounted, View};
/// `true` si `view` o algún descendiente declara un [`crate::View::layout_builder`].
/// El runtime lo usa para decidir si vale la pena la resolución en dos pasadas;
/// cuando es `false` (lo normal) el camino diferido se evita por completo.
pub fn has_layout_builder<Msg>(view: &View<Msg>) -> bool {
view.layout_builder.is_some() || view.children.iter().any(has_layout_builder)
}
/// Lee las [`Constraints`] (tamaño del slot) de cada nodo `is_layout_builder`
/// del árbol montado, en pre-orden. El runtime las pasa a
/// [`expand_layout_builders`]. Un nodo sin rect computado (fuera del layout)
/// cae a `0×0`.
pub fn collect_builder_constraints<Msg>(
mounted: &Mounted<Msg>,
computed: &ComputedLayout,
) -> Vec<Constraints> {
mounted
.nodes
.iter()
.filter(|n| n.is_layout_builder)
.map(|n| {
computed
.get(n.id)
.map(|r| Constraints { max_width: r.w, max_height: r.h })
.unwrap_or(Constraints { max_width: 0.0, max_height: 0.0 })
})
.collect()
}
/// Expande los `layout_builder` de `view` (pre-orden) usando `cons` — una
/// [`Constraints`] por builder, en el orden que produjo
/// [`collect_builder_constraints`]. Cada builder se reemplaza por un nodo
/// contenedor (su mismo `Style`) cuyo único hijo es lo que devolvió la closure
/// invocada con sus constraints. Builders sin constraint correspondiente (más
/// builders que `cons`, p. ej. uno anidado recién producido) caen a `0×0` y se
/// resuelven igual, pero su tamaño será nulo (límite v1: sin anidamiento).
/// Consume `view`.
pub fn expand_layout_builders<Msg>(view: View<Msg>, cons: &[Constraints]) -> View<Msg> {
let mut idx = 0;
expand_rec(view, cons, &mut idx)
}
fn expand_rec<Msg>(mut view: View<Msg>, cons: &[Constraints], idx: &mut usize) -> View<Msg> {
if let Some(builder) = view.layout_builder.take() {
let c = cons
.get(*idx)
.copied()
.unwrap_or(Constraints { max_width: 0.0, max_height: 0.0 });
*idx += 1;
// El builder posee los hijos: descartamos cualquier `children` estático
// y ponemos lo que produjo la closure. NO recursamos en el resultado
// (v1 sin anidamiento — un builder interno queda como hoja al montarse).
let child = builder(c);
view.children = vec![child];
view
} else {
let children = std::mem::take(&mut view.children);
view.children = children
.into_iter()
.map(|c| expand_rec(c, cons, idx))
.collect();
view
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{mount, Constraints};
use llimphi_layout::taffy::prelude::*;
use llimphi_layout::{LayoutTree, Style};
/// Árbol sin builders → `has_layout_builder` falso y expand es no-op.
#[test]
fn sin_builder_es_noop() {
let v = View::<()>::new(Style::default())
.children(vec![View::<()>::new(Style::default())]);
assert!(!has_layout_builder(&v));
let v = expand_layout_builders(v, &[]);
assert_eq!(v.children.len(), 1);
}
#[test]
fn detecta_builder_anidado_en_hijos() {
let v = View::<()>::new(Style::default()).children(vec![
View::<()>::new(Style::default()),
View::<()>::new(Style::default()).layout_builder(|_c| View::<()>::new(Style::default())),
]);
assert!(has_layout_builder(&v));
}
/// El builder recibe las constraints y produce su subárbol; el nodo deja de
/// ser builder y queda como contenedor con el hijo producido.
#[test]
fn expand_invoca_closure_con_constraints() {
// Dos columnas a percent(0.5) del root 400px → cada slot = 200px. La de
// la izquierda es un builder que mete 1 hijo si es angosta (<300) o 2 si
// es ancha. A 200px mete 1.
let build_col = |c: Constraints| {
let n = if c.max_width < 300.0 { 1 } else { 2 };
View::<()>::new(Style::default())
.children((0..n).map(|_| View::<()>::new(Style::default())).collect())
};
let root = View::<()>::new(Style {
size: Size { width: length(400.0), height: length(100.0) },
flex_direction: FlexDirection::Row,
..Default::default()
})
.children(vec![
View::<()>::new(Style {
size: Size { width: percent(0.5), height: percent(1.0) },
..Default::default()
})
.layout_builder(build_col),
View::<()>::new(Style {
size: Size { width: percent(0.5), height: percent(1.0) },
..Default::default()
}),
]);
// Pasada 1: montar (builder como hoja) y computar.
let mut l1 = LayoutTree::new();
let m1 = mount(&mut l1, root);
let c1 = l1.compute(m1.root, (400.0, 100.0)).expect("layout");
let cons = collect_builder_constraints(&m1, &c1);
assert_eq!(cons.len(), 1, "un solo builder");
assert!((cons[0].max_width - 200.0).abs() < 1.0, "slot 200px: {:?}", cons[0]);
// Pasada 2: árbol fresco (mismo Style) + expand.
let root2 = View::<()>::new(Style {
size: Size { width: length(400.0), height: length(100.0) },
flex_direction: FlexDirection::Row,
..Default::default()
})
.children(vec![
View::<()>::new(Style {
size: Size { width: percent(0.5), height: percent(1.0) },
..Default::default()
})
.layout_builder(build_col),
View::<()>::new(Style {
size: Size { width: percent(0.5), height: percent(1.0) },
..Default::default()
}),
]);
let expanded = expand_layout_builders(root2, &cons);
// El nodo builder (hijo 0 del root) ya no es builder y tiene 1 hijo
// producido (slot 200 < 300 → angosto → 1 columna).
let col_izq = &expanded.children[0];
assert!(col_izq.layout_builder.is_none(), "ya expandido");
assert_eq!(col_izq.children.len(), 1, "200px angosto → 1 hijo");
}
/// Con un slot ancho el mismo builder produce 2 hijos — verifica que la
/// rama de decisión depende de las constraints reales.
#[test]
fn slot_ancho_produce_mas_hijos() {
let build_col = |c: Constraints| {
let n = if c.max_width < 300.0 { 1 } else { 2 };
View::<()>::new(Style::default())
.children((0..n).map(|_| View::<()>::new(Style::default())).collect())
};
// Constraint inyectada directo: 500px → ancho. El builder devuelve UN
// contenedor (hijo único del nodo) con 2 columnas adentro.
let v = View::<()>::new(Style::default()).layout_builder(build_col);
let expanded = expand_layout_builders(v, &[Constraints { max_width: 500.0, max_height: 100.0 }]);
assert_eq!(expanded.children.len(), 1, "el builder produce 1 contenedor");
assert_eq!(expanded.children[0].children.len(), 2, "ancho → 2 columnas");
}
/// Pre-orden: dos builders hermanos reciben sus constraints en orden.
#[test]
fn dos_builders_reciben_constraints_en_preorden() {
let mk = |w: f32| {
move |_c: Constraints| {
View::<()>::new(Style {
size: Size { width: length(w), height: length(10.0) },
..Default::default()
})
}
};
let root = View::<()>::new(Style::default()).children(vec![
View::<()>::new(Style::default()).layout_builder(mk(1.0)),
View::<()>::new(Style::default()).layout_builder(mk(2.0)),
]);
let cons = vec![
Constraints { max_width: 111.0, max_height: 0.0 },
Constraints { max_width: 222.0, max_height: 0.0 },
];
let expanded = expand_layout_builders(root, &cons);
// Ambos expandidos, en orden (verificamos vía el ancho del hijo producido
// que NO depende de la constraint acá — sólo confirmamos que se invocaron
// los dos y que ninguno quedó como builder).
assert!(expanded.children[0].layout_builder.is_none());
assert!(expanded.children[1].layout_builder.is_none());
assert_eq!(expanded.children[0].children.len(), 1);
assert_eq!(expanded.children[1].children.len(), 1);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+277
View File
@@ -0,0 +1,277 @@
//! **Ripple / InkWell** — el feedback de tap de Material: un círculo que se
//! expande desde el punto donde el dedo/cursor presionó, clipeado al contorno
//! del nodo, desvaneciéndose mientras crece. Es puro feedback visual: no vive
//! en el `Model` de la app (igual que las animaciones implícitas de
//! [`crate::AnimRegistry`]) sino en un registro retenido por el runtime entre
//! frames.
//!
//! **Flujo.** Un `View` se marca ripple-capaz con [`crate::View::ripple`]
//! (key estable + color). Cuando un press izquierdo cae sobre ese nodo, el
//! runtime hace [`crate::hit_test_ripple`], calcula el punto local del tap y
//! llama [`RippleRegistry::trigger`] — que guarda una "salpicadura" con su
//! reloj. En cada redraw, DESPUÉS del paint del contenido, el runtime llama
//! [`RippleRegistry::paint`], que por cada salpicadura viva resuelve el rect
//! actual del nodo (puede haber cambiado de tamaño), dibuja el círculo
//! expansivo recortado al rrect del nodo y devuelve `true` si alguna sigue
//! viva → el runtime pide otro frame (ticker autodetenido, sin `spawn_periodic`).
//!
//! **Aditivo.** El ripple NO toca el camino click/drag: se dispara en el press
//! por su propio hit-test, conviva o no el nodo con `on_click`. Un botón normal
//! (`on_click` + `.ripple(...)`) recibe ambos.
//!
//! **Limitación v1.** Como la captura de subescenas del fade-out
//! ([`crate::AnimRegistry`]), el paint usa el rect en coordenadas absolutas del
//! layout e ignora los `transform` de ancestros — alcanza para botones/cards
//! (rara vez transformados). La salpicadura es one-shot (expande + se desvanece
//! en `duration`); no hay "mantener mientras se sostiene el press" (Material
//! `hold`), que requeriría rastrear el release por key.
use std::time::{Duration, Instant};
use vello::kurbo::{Affine, Circle};
use vello::peniko::{BlendMode, Color, Fill};
use vello::Scene;
use crate::{ComputedLayout, Mounted};
/// Declara que este nodo emite un **ripple** (salpicadura Material) al recibir
/// un press. `key` debe ser estable entre rebuilds del `View` (igual que la
/// key de [`crate::Anim`]) — es lo que enlaza la salpicadura retenida con el
/// nodo entre frames. `color` es el tinte de la onda (típicamente
/// semitransparente, p. ej. blanco a alpha ~0.25 sobre superficies oscuras o
/// negro a alpha ~0.12 sobre claras); su alpha se multiplica por el fade.
#[derive(Clone, Copy, Debug)]
pub struct Ripple {
pub key: u64,
pub color: Color,
pub duration: Duration,
}
/// Una salpicadura viva: el punto de origen **relativo al rect del nodo** al
/// momento del press, su color/duración y el reloj de expansión.
struct Splash {
key: u64,
/// Origen del tap relativo a la esquina superior-izquierda del rect del
/// nodo (mismo espacio que los handlers `*_at`). Se reancla al rect actual
/// del nodo en cada frame, así la onda sigue al nodo si éste se mueve.
lx: f32,
ly: f32,
color: Color,
start: Instant,
duration: Duration,
easing: fn(f32) -> f32,
}
impl Splash {
/// Progreso `[0,1]` sin easing (lineal en el tiempo).
fn raw(&self, now: Instant) -> f32 {
if self.duration.is_zero() {
return 1.0;
}
let elapsed = now.saturating_duration_since(self.start).as_secs_f32();
(elapsed / self.duration.as_secs_f32()).clamp(0.0, 1.0)
}
fn done(&self, now: Instant) -> bool {
now.saturating_duration_since(self.start) >= self.duration
}
}
/// Registro de ripples vivos, retenido por el runtime entre frames. Una
/// instancia por ventana; el runtime llama [`Self::trigger`] en el press y
/// [`Self::paint`] tras el paint del contenido.
#[derive(Default)]
pub struct RippleRegistry {
splashes: Vec<Splash>,
}
impl RippleRegistry {
pub fn new() -> Self {
Self::default()
}
/// Registra una salpicadura nueva sobre el nodo de key `key`, originada en
/// `(lx, ly)` relativo a su rect. `now` es el instante del press. Varios
/// presses rápidos apilan ondas concurrentes (como Material).
pub fn trigger(
&mut self,
key: u64,
lx: f32,
ly: f32,
color: Color,
duration: Duration,
now: Instant,
) {
self.splashes.push(Splash {
key,
lx,
ly,
color,
start: now,
duration,
easing: crate::ease_out_cubic,
});
}
/// `true` si hay alguna salpicadura viva (el runtime ya lo sabe por el
/// retorno de [`Self::paint`], pero es cómodo para decidir antes).
pub fn animating(&self) -> bool {
!self.splashes.is_empty()
}
/// Pinta las salpicaduras vivas sobre `scene`, cada una como un círculo que
/// crece (radio con ease-out hasta cubrir el nodo) y se desvanece, recortado
/// al contorno redondeado del nodo. Resuelve el rect de cada nodo por su
/// `ripple.key` en `mounted`/`computed` (así sigue al nodo si se redimensiona).
/// Descarta las agotadas. Devuelve `true` si queda alguna viva → pedir frame.
///
/// Llamar DESPUÉS del paint del contenido (la onda va encima, translúcida).
pub fn paint<Msg>(
&mut self,
scene: &mut Scene,
mounted: &Mounted<Msg>,
computed: &ComputedLayout,
now: Instant,
) -> bool {
// Descartá primero las agotadas (no dependen del nodo).
self.splashes.retain(|s| !s.done(now));
if self.splashes.is_empty() {
return false;
}
for s in &self.splashes {
// Resolvé el nodo ripple de esta key (el primero que la declare).
let Some(node) = mounted.nodes.iter().find(|n| {
n.ripple.map(|r| r.key) == Some(s.key)
}) else {
continue;
};
let Some(r) = computed.get(node.id) else {
continue;
};
if r.w <= 0.0 || r.h <= 0.0 {
continue;
}
let cx = r.x as f64 + s.lx as f64;
let cy = r.y as f64 + s.ly as f64;
// Radio máximo = distancia al rincón más lejano, así la onda llega a
// cubrir todo el nodo cualquiera sea el punto de origen.
let corners = [
(r.x as f64, r.y as f64),
((r.x + r.w) as f64, r.y as f64),
(r.x as f64, (r.y + r.h) as f64),
((r.x + r.w) as f64, (r.y + r.h) as f64),
];
let max_radius = corners
.iter()
.map(|(px, py)| ((px - cx).powi(2) + (py - cy).powi(2)).sqrt())
.fold(0.0_f64, f64::max);
let t = s.raw(now);
let radius = (s.easing)(t) as f64 * max_radius;
if radius <= 0.0 {
continue;
}
// Fade: la onda arranca a su alpha y se apaga al expandirse.
let fade = 1.0 - t;
let mut col = s.color;
col.components[3] *= fade;
if col.components[3] <= 0.0 {
continue;
}
// Recorte al contorno del nodo (respeta radio/esquinas), para que la
// onda no sangre fuera de un botón redondeado.
let rrect = crate::render::node_rrect(
r.x as f64,
r.y as f64,
(r.x + r.w) as f64,
(r.y + r.h) as f64,
node.radius,
node.corner_radii,
0.0,
);
scene.push_layer(Fill::NonZero, BlendMode::default(), 1.0, Affine::IDENTITY, &rrect);
let circle = Circle::new((cx, cy), radius);
scene.fill(Fill::NonZero, Affine::IDENTITY, col, None, &circle);
scene.pop_layer();
}
!self.splashes.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{mount, View};
use llimphi_layout::taffy::prelude::*;
use llimphi_layout::{LayoutTree, Style};
fn rgba(r: u8, g: u8, b: u8, a: u8) -> Color {
Color::from_rgba8(r, g, b, a)
}
/// Monta un botón 100×100 con ripple(key=5) y devuelve (mounted, computed).
fn boton() -> (Mounted<()>, ComputedLayout) {
let v = View::<()>::new(Style {
size: Size { width: length(100.0), height: length(100.0) },
..Default::default()
})
.ripple(5, rgba(255, 255, 255, 80));
let mut layout = LayoutTree::new();
let m = mount(&mut layout, v);
let c = layout.compute(m.root, (200.0, 200.0)).expect("layout");
(m, c)
}
#[test]
fn sin_trigger_no_anima() {
let mut reg = RippleRegistry::new();
let (m, c) = boton();
let mut scene = Scene::new();
assert!(!reg.paint(&mut scene, &m, &c, Instant::now()));
assert!(!reg.animating());
}
#[test]
fn trigger_anima_y_se_autodetiene() {
let mut reg = RippleRegistry::new();
let (m, c) = boton();
let t0 = Instant::now();
reg.trigger(5, 50.0, 50.0, rgba(255, 255, 255, 80), Duration::from_millis(200), t0);
assert!(reg.animating(), "tras el trigger hay onda viva");
let mut scene = Scene::new();
// A mitad de la duración sigue animando.
assert!(reg.paint(&mut scene, &m, &c, t0 + Duration::from_millis(100)));
// Pasada la duración, se descarta y el ticker para.
assert!(!reg.paint(&mut scene, &m, &c, t0 + Duration::from_millis(250)));
assert!(!reg.animating());
}
#[test]
fn presses_concurrentes_apilan_ondas() {
let mut reg = RippleRegistry::new();
let t0 = Instant::now();
reg.trigger(5, 10.0, 10.0, rgba(255, 255, 255, 80), Duration::from_millis(200), t0);
reg.trigger(5, 90.0, 90.0, rgba(255, 255, 255, 80), Duration::from_millis(200), t0 + Duration::from_millis(20));
assert_eq!(reg.splashes.len(), 2);
let (m, c) = boton();
let mut scene = Scene::new();
// En t0+100 la primera vive (80ms restantes) y la segunda también.
assert!(reg.paint(&mut scene, &m, &c, t0 + Duration::from_millis(100)));
assert_eq!(reg.splashes.len(), 2);
// En t0+210 la primera murió (210>200) pero la segunda vive (190<200).
assert!(reg.paint(&mut scene, &m, &c, t0 + Duration::from_millis(210)));
assert_eq!(reg.splashes.len(), 1);
}
#[test]
fn key_inexistente_se_descarta_al_agotarse_sin_panico() {
// Una onda cuya key no existe en el árbol no debe pintar ni panico;
// simplemente no encuentra nodo y se descarta cuando su reloj vence.
let mut reg = RippleRegistry::new();
let t0 = Instant::now();
reg.trigger(999, 0.0, 0.0, rgba(255, 255, 255, 80), Duration::from_millis(100), t0);
let (m, c) = boton();
let mut scene = Scene::new();
assert!(reg.paint(&mut scene, &m, &c, t0 + Duration::from_millis(50)));
assert!(!reg.paint(&mut scene, &m, &c, t0 + Duration::from_millis(150)));
}
}
+226
View File
@@ -0,0 +1,226 @@
//! Modelo de **semántica accesible** de un nodo. Es el dato que el runtime
//! traduce a un árbol [AccessKit](https://accesskit.dev) por frame para
//! alimentar lectores de pantalla (NVDA, VoiceOver, Orca, TalkBack) y otras
//! ayudas técnicas — TTS, navegación por voz, switch control.
//!
//! Este módulo es **pura data**: define los tipos sin acoplarse al crate
//! `accesskit`. La conversión a `accesskit::Node` vive en `llimphi-ui::a11y`
//! (iter 2 del plan), donde el cableado del adapter winit ya importa la
//! librería. Tener acá solo el modelo permite:
//!
//! - Compilar el compositor con o sin la integración AccessKit habilitada.
//! - Testear semántica a nivel "qué declaran los widgets" sin levantar un
//! adapter ni un lector real.
//! - Mantener la API estable aunque cambien versiones de `accesskit`.
//!
//! ## Cuándo declarar semántica
//!
//! - **Siempre** en controles interactivos: botones, inputs, checkboxes, tabs,
//! ítems de menú, sliders. Sin rol declarado, el lector no sabe que el nodo
//! ES un botón aunque tenga `on_click`.
//! - **Para texto significativo** que no es un botón: títulos (`Heading`),
//! etiquetas asociadas, valores (`Label` / `Static`). El text de un nodo se
//! lee igual aunque no tenga `semantics`, pero un rol explícito mejora la
//! navegación por rol de los lectores.
//! - **Para grouping**: tabbar, dock, toolbars, listas — `Role::Group` o un
//! rol específico (`TabList`, `Menu`, `Toolbar`) ayuda a saltar bloques.
//!
//! ## Cuándo NO declarar
//!
//! Decorativo puro (un divider, un fondo con gradiente, una sombra) **no debe**
//! declarar semántica — los lectores ya filtran texto vacío, pero un rol
//! superfluo (`Role::Group` en cada `View` envoltorio) ensucia la navegación.
use std::sync::Arc;
/// Rol semántico del nodo. Los nombres y la granularidad siguen los roles de
/// AccessKit / ARIA. Subset acotado: agregamos lo que falte cuando aparezca un
/// caller real (regla del repo — no diseñamos para lo hipotético).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Role {
/// Botón clickeable. El lector dice "botón <label>" + el flag `pressed`
/// para botones de toggle.
Button,
/// Campo de texto editable (single-line o multi-line). Combinable con
/// `value` (texto actual) y los flags `readonly`/`required`.
TextInput,
/// Título de sección (h1..h6 en HTML). El `value` puede llevar el nivel
/// como string ("1", "2", …) si la app lo necesita; v1 no lo distingue.
Heading,
/// Casilla de verificación. Combina con `checked`.
Checkbox,
/// Texto estático significativo (no interactivo, no título). Si solo es
/// decorativo, no declarar semántica.
Label,
/// Hipervínculo / acción que navega a otra ubicación.
Link,
/// Ítem de un menú (context-menu, menubar, dropdown).
MenuItem,
/// Pestaña de un tabbar / segmented control.
Tab,
/// Imagen significativa. El `label` actúa como alt-text.
Image,
/// Control deslizable continuo (volumen, brillo, range). Combinable con
/// `value` (string del valor actual) — los rangos numéricos se modelan
/// más fino en iter posteriores si hace falta.
Slider,
/// Agrupador genérico (toolbar, panel, sección). Sirve para que los
/// lectores ofrezcan "saltar al siguiente grupo".
Group,
}
/// Banderas booleanas del nodo accesible. Todas opcionales (`None` = no aplica,
/// que es distinto de "aplica pero es false"). Mantienelas en None salvo que el
/// widget realmente las exponga — los lectores diferencian "no es checkable" de
/// "es checkable y no checked".
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct SemanticsFlags {
/// Estado de un checkbox / radio / toggle button.
pub checked: Option<bool>,
/// Estado on/off de un botón de toggle (separado de `checked` porque ARIA
/// los distingue: un toggle `<button>` usa `aria-pressed`, una checkbox
/// `aria-checked`).
pub pressed: Option<bool>,
/// Para acordeones, menús, tree-rows que se expanden.
pub expanded: Option<bool>,
/// El control está deshabilitado (no responde a input).
pub disabled: Option<bool>,
/// Sólo lectura (típicamente input de texto que no se edita).
pub readonly: Option<bool>,
/// Campo requerido (formularios).
pub required: Option<bool>,
}
impl SemanticsFlags {
pub const EMPTY: Self = Self {
checked: None,
pressed: None,
expanded: None,
disabled: None,
readonly: None,
required: None,
};
}
/// Especificación semántica completa de un nodo. Lo que el runtime traduce a
/// un `accesskit::Node` cada frame.
///
/// `label` es lo que el lector enuncia primero (el "nombre accesible"). Si el
/// nodo ya tiene un `text` visible y significativo, podés dejar `label = None`
/// y el runtime usará ese texto como nombre — pero declararlo explícito es más
/// robusto (e.g. un botón con sólo un ícono necesita label porque no hay texto
/// visible).
///
/// `value` es el dato dinámico (texto del input, valor del slider). El lector
/// suele leer label + value juntos: "Volumen, 70".
///
/// `description` es contexto adicional ("Disminuye el volumen del sistema").
/// Los lectores lo leen tras una pausa o con un atajo distinto; usalo para
/// info que ayude PERO no sobreloadées (los usuarios de TTS perciben ruido
/// más que falta de info).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct SemanticsSpec {
pub role: Option<Role>,
pub label: Option<Arc<str>>,
pub description: Option<Arc<str>>,
pub value: Option<Arc<str>>,
pub flags: SemanticsFlags,
}
impl SemanticsSpec {
/// Especificación con sólo el rol fijado. Atajo común; los demás campos
/// quedan `None` y los flags vacíos.
pub fn role(role: Role) -> Self {
Self {
role: Some(role),
..Self::default()
}
}
/// Pone `label` (consumiendo cualquier valor previo).
pub fn with_label(mut self, s: impl Into<Arc<str>>) -> Self {
self.label = Some(s.into());
self
}
/// Pone `description`.
pub fn with_description(mut self, s: impl Into<Arc<str>>) -> Self {
self.description = Some(s.into());
self
}
/// Pone `value`.
pub fn with_value(mut self, s: impl Into<Arc<str>>) -> Self {
self.value = Some(s.into());
self
}
/// Pone `flags.checked = Some(v)`.
pub fn with_checked(mut self, v: bool) -> Self {
self.flags.checked = Some(v);
self
}
pub fn with_pressed(mut self, v: bool) -> Self {
self.flags.pressed = Some(v);
self
}
pub fn with_expanded(mut self, v: bool) -> Self {
self.flags.expanded = Some(v);
self
}
pub fn with_disabled(mut self, v: bool) -> Self {
self.flags.disabled = Some(v);
self
}
pub fn with_readonly(mut self, v: bool) -> Self {
self.flags.readonly = Some(v);
self
}
pub fn with_required(mut self, v: bool) -> Self {
self.flags.required = Some(v);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_es_todo_none_y_flags_empty() {
let s = SemanticsSpec::default();
assert!(s.role.is_none());
assert!(s.label.is_none());
assert!(s.value.is_none());
assert_eq!(s.flags, SemanticsFlags::EMPTY);
}
#[test]
fn role_builder_pone_solo_el_rol() {
let s = SemanticsSpec::role(Role::Button);
assert_eq!(s.role, Some(Role::Button));
assert!(s.label.is_none());
assert!(s.value.is_none());
assert_eq!(s.flags, SemanticsFlags::EMPTY);
}
#[test]
fn with_label_y_with_value_componen() {
let s = SemanticsSpec::role(Role::Slider)
.with_label("Volumen")
.with_value("70");
assert_eq!(s.role, Some(Role::Slider));
assert_eq!(s.label.as_deref(), Some("Volumen"));
assert_eq!(s.value.as_deref(), Some("70"));
}
#[test]
fn flags_con_with_son_independientes() {
let s = SemanticsSpec::role(Role::Checkbox)
.with_checked(true)
.with_required(true);
assert_eq!(s.flags.checked, Some(true));
assert_eq!(s.flags.required, Some(true));
assert!(s.flags.disabled.is_none(), "no setear flags no tocados");
}
}
File diff suppressed because it is too large Load Diff