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:
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
+1488
-99
File diff suppressed because it is too large
Load Diff
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
+1350
-5
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user