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
+26
View File
@@ -14,6 +14,24 @@ llimphi-text = { path = "../llimphi-text" }
# El compositor declarativo (winit-free): View, mount, paint, hit-test.
llimphi-compositor = { path = "../llimphi-compositor" }
pollster = { workspace = true }
# Árbol de accesibilidad por frame (NVDA/VoiceOver/Orca/TalkBack). Lo
# alimentamos desde `View::semantics` + el árbol Mounted. `accesskit_winit`
# es el adapter que conecta el árbol a la API nativa del SO vía winit.
accesskit = { workspace = true }
accesskit_winit = { workspace = true }
# `accesskit::TreeId` envuelve un `uuid::Uuid` — generamos uno por proceso
# para identificar el árbol entre actualizaciones (el lector lo necesita
# para distinguir nuestra app de otras ventanas AccessKit del SO).
uuid = { version = "1", features = ["v4"] }
# Portapapeles del sistema para copiar texto seleccionado fuera del editor
# (Ctrl/Cmd+C). Best-effort: si no hay backend (headless, android) degrada a
# no-op sin panicar. Feature `clipboard` (default) para que builds sin display
# o targets sin arboard puedan apagarlo con --no-default-features.
arboard = { workspace = true, optional = true }
[features]
default = ["clipboard"]
clipboard = ["dep:arboard"]
[[example]]
name = "counter"
@@ -26,3 +44,11 @@ path = "examples/editor.rs"
[[example]]
name = "gpu_paint_demo"
path = "examples/gpu_paint_demo.rs"
[[example]]
name = "gestos"
path = "examples/gestos.rs"
[[example]]
name = "selectable_text"
path = "examples/selectable_text.rs"
+183
View File
@@ -0,0 +1,183 @@
//! Demo de la **arena de gestos** de Llimphi (Tier 4 de PARIDAD-FLUTTER).
//!
//! Un canvas pannable + zoomable que ejercita los tres gestos nuevos:
//!
//! - **Ctrl + rueda** → `on_scale`: zoom hacia el cursor (camino universal de
//! desktop; en macOS también responde al pinch del trackpad).
//! - **Arrastrar** (botón izquierdo) → `draggable`: paneo. Mover cancela un
//! long-press en curso — esa desambiguación es la "arena".
//! - **Doble-click** → `on_double_tap`: resetea zoom y paneo.
//! - **Mantener apretado ~500 ms quieto** → `on_long_press_at`: deja una marca
//! en el punto (coordenadas de mundo, así sigue al zoom/paneo).
//!
//! La barra inferior muestra el zoom, la cantidad de marcas y el último gesto.
//!
//! Corre con: `cargo run -p llimphi-ui --example gestos --release`.
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, Dimension, FlexDirection, Size, Style},
AlignItems, JustifyContent,
};
use llimphi_ui::llimphi_raster::kurbo::{Affine, Circle, Line, Stroke};
use llimphi_ui::llimphi_raster::peniko::{Color, Fill};
use llimphi_ui::{App, DragPhase, GesturePhase, Handle, View};
#[derive(Clone)]
enum Msg {
/// Zoom incremental con factor multiplicativo + punto focal (local al canvas).
Zoom { factor: f32, fx: f32, fy: f32 },
/// Paneo por delta de arrastre.
Pan { dx: f32, dy: f32 },
/// Doble-tap: resetear la vista.
Reset,
/// Long-press: dejar una marca en el punto (local al canvas).
Mark { lx: f32, ly: f32 },
}
struct Model {
zoom: f32,
pan: (f32, f32),
/// Marcas en coordenadas de **mundo** (independientes del zoom/paneo).
marks: Vec<(f32, f32)>,
last: String,
}
struct Gestos;
impl App for Gestos {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"llimphi · gestos (pinch-zoom · double-tap · long-press)"
}
fn initial_size() -> (u32, u32) {
(900, 640)
}
fn init(_: &Handle<Self::Msg>) -> Self::Model {
Model {
zoom: 1.0,
pan: (0.0, 0.0),
marks: Vec::new(),
last: "probá: Ctrl+rueda (zoom) · arrastrar (paneo) · doble-click (reset) · mantener (marca)".into(),
}
}
fn update(mut model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
match msg {
Msg::Zoom { factor, fx, fy } => {
// Zoom hacia el cursor: mantené fijo el punto de mundo bajo
// (fx, fy) reajustando el paneo. new_pan = focal - rf·(focal - pan).
let new_zoom = (model.zoom * factor).clamp(0.15, 12.0);
let rf = new_zoom / model.zoom; // factor real tras el clamp
model.pan.0 = fx - rf * (fx - model.pan.0);
model.pan.1 = fy - rf * (fy - model.pan.1);
model.zoom = new_zoom;
model.last = format!("zoom ×{:.2}", model.zoom);
}
Msg::Pan { dx, dy } => {
model.pan.0 += dx;
model.pan.1 += dy;
model.last = "paneo".into();
}
Msg::Reset => {
model.zoom = 1.0;
model.pan = (0.0, 0.0);
model.last = "doble-tap → reset".into();
}
Msg::Mark { lx, ly } => {
// Local del canvas → mundo: (local - pan) / zoom.
let wx = (lx - model.pan.0) / model.zoom;
let wy = (ly - model.pan.1) / model.zoom;
model.marks.push((wx, wy));
model.last = format!("long-press → marca #{} @ ({wx:.0}, {wy:.0})", model.marks.len());
}
}
model
}
fn view(model: &Self::Model) -> View<Self::Msg> {
let zoom = model.zoom;
let pan = model.pan;
let marks = model.marks.clone();
let canvas = View::new(Style {
size: Size { width: percent(1.0_f32), height: Dimension::auto() },
flex_grow: 1.0,
..Default::default()
})
.fill(Color::from_rgba8(16, 18, 26, 255))
.clip(true)
.paint_with(move |scene, _ts, rect| {
// Grilla de mundo paso 40px, escalada por zoom y desplazada por pan.
let step = 40.0 * zoom as f64;
if step >= 4.0 {
let thin = Stroke::new(1.0);
let grid = Color::from_rgba8(40, 46, 60, 255);
// Offset del primer línea visible (pan módulo step).
let ox = (rect.x as f64) + (pan.0 as f64).rem_euclid(step);
let mut x = ox;
while x < (rect.x + rect.w) as f64 {
scene.stroke(&thin, Affine::IDENTITY, grid, None,
&Line::new((x, rect.y as f64), (x, (rect.y + rect.h) as f64)));
x += step;
}
let oy = (rect.y as f64) + (pan.1 as f64).rem_euclid(step);
let mut y = oy;
while y < (rect.y + rect.h) as f64 {
scene.stroke(&thin, Affine::IDENTITY, grid, None,
&Line::new((rect.x as f64, y), ((rect.x + rect.w) as f64, y)));
y += step;
}
}
// Marcas (coords de mundo → pantalla): pan + world·zoom.
let dot = Color::from_rgba8(90, 220, 150, 255);
let r = (6.0 * zoom as f64).clamp(3.0, 24.0);
for (wx, wy) in &marks {
let sx = rect.x as f64 + pan.0 as f64 + (*wx as f64) * zoom as f64;
let sy = rect.y as f64 + pan.1 as f64 + (*wy as f64) * zoom as f64;
scene.fill(Fill::NonZero, Affine::IDENTITY, dot, None, &Circle::new((sx, sy), r));
}
})
// Pinch-to-zoom (Ctrl+rueda / trackpad). El focal viene local al canvas.
.on_scale(|phase, factor, fx, fy| match phase {
GesturePhase::Update => Some(Msg::Zoom { factor, fx, fy }),
_ => None,
})
// Paneo por arrastre. El movimiento cancela un long-press en curso.
.draggable(|phase, dx, dy| match phase {
DragPhase::Move => Some(Msg::Pan { dx, dy }),
DragPhase::End => None,
})
// Doble-tap: reset. Long-press: marca en el punto.
.on_double_tap(Msg::Reset)
.on_long_press_at(|lx, ly, _w, _h| Some(Msg::Mark { lx, ly }));
let status = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(40.0_f32) },
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.fill(Color::from_rgba8(28, 32, 42, 255))
.text(
format!("{} · ×{:.2} · {} marcas", model.last, model.zoom, model.marks.len()),
18.0,
Color::from_rgba8(210, 220, 235, 255),
);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.fill(Color::from_rgba8(16, 18, 26, 255))
.children(vec![canvas, status])
}
}
fn main() {
llimphi_ui::run::<Gestos>();
}
+1
View File
@@ -177,6 +177,7 @@ fn draw_points(
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
// Load preserva el fondo vello ya pintado en este frame.
load: wgpu::LoadOp::Load,
+102
View File
@@ -0,0 +1,102 @@
//! Texto seleccionable **fuera del editor**: arrastrá el mouse sobre los
//! párrafos para resaltar y Ctrl/Cmd+C para copiar al portapapeles. La
//! selección la maneja el runtime (`View::selectable(key)`) — la app no
//! guarda estado de selección en su `Model`.
//!
//! Corre con: `cargo run -p llimphi-ui --example selectable_text --release`.
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, Dimension, FlexDirection, Rect, Size, Style},
AlignItems,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{App, Handle, View};
struct Demo;
const PARRAFOS: [&str; 3] = [
"Arrastrá el cursor sobre este texto para seleccionarlo. La selección \
vive en el runtime de Llimphi, no en el Model de la app.",
"Cada párrafo tiene su propia key estable; empezar a arrastrar en otro \
reemplaza la selección anterior. Ctrl+C (o Cmd+C en macOS) copia el \
rango resaltado al portapapeles del sistema.",
"No es un editor: es texto de sólo lectura que igual se puede leer, \
resaltar y copiar — labels, párrafos, celdas, salidas de consola.",
];
impl App for Demo {
type Model = ();
type Msg = ();
fn title() -> &'static str {
"llimphi · texto seleccionable"
}
fn init(_: &Handle<Self::Msg>) -> Self::Model {}
fn update(_: Self::Model, _: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {}
fn view(_: &Self::Model) -> View<Self::Msg> {
let mut children: Vec<View<()>> = vec![View::new(Style {
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
..Default::default()
})
.text_aligned(
"Texto seleccionable (arrastrá + Ctrl/Cmd+C)",
22.0,
Color::from_rgba8(230, 240, 250, 255),
Alignment::Start,
)];
for (i, p) in PARRAFOS.iter().enumerate() {
children.push(
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
..Default::default()
})
.text_aligned(
*p,
16.0,
Color::from_rgba8(205, 214, 226, 255),
Alignment::Start,
)
// La línea clave: cada párrafo es seleccionable con una key
// estable (su índice). El resaltado + copy los hace el runtime.
.selectable(i as u64),
);
}
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
gap: Size {
width: length(0.0_f32),
height: length(20.0_f32),
},
padding: Rect {
left: length(40.0_f32),
right: length(40.0_f32),
top: length(36.0_f32),
bottom: length(36.0_f32),
},
align_items: Some(AlignItems::Start),
..Default::default()
})
.fill(Color::from_rgba8(20, 24, 32, 255))
.children(children)
}
}
fn main() {
llimphi_ui::run::<Demo>();
}
+310
View File
@@ -0,0 +1,310 @@
//! Traducción del árbol Llimphi a un árbol [AccessKit](https://accesskit.dev)
//! para alimentar lectores de pantalla y otras tecnologías de asistencia.
//!
//! Cada frame el runtime llama a [`build_tree`] con el árbol montado +
//! `ComputedLayout` + el id de foco actual. La función produce un
//! `accesskit::TreeUpdate` que el adapter (`accesskit_winit::Adapter`) empuja
//! al sistema operativo.
//!
//! ## Mapeo de identidades
//!
//! Cada `MountedNode` recibe un `NodeId(idx + ROOT_OFFSET)` derivado de su
//! índice en `Mounted::nodes` — estable dentro de un frame, no necesariamente
//! entre frames (si la app re-renderiza un árbol distinto, los ids cambian).
//! `ROOT_NODE_ID` queda reservado para el nodo raíz sintético que envuelve
//! todo el árbol. Los nodos sin semántica declarada igual aparecen en el árbol
//! si contienen texto, son `focusable` o tienen `on_click` — los lectores los
//! anuncian aunque el caller no haya marcado un rol explícito.
//!
//! ## Acciones soportadas (v1)
//!
//! - `Action::Focus`: mueve el foco de Llimphi a ese nodo (vía
//! [`crate::App::on_focus`]).
//! - `Action::Click` / `Default`: ejecuta el `on_click` del nodo si existe;
//! los handlers `*_at` se ignoran en v1 (no tienen una posición sintética
//! coherente — la documentamos como limitación).
use accesskit::{Action, Node, NodeId, Rect as AkRect, Role as AkRole, Tree, TreeId, TreeUpdate};
use llimphi_compositor::{Mounted, Role as LRole, SemanticsSpec};
use llimphi_layout::ComputedLayout;
/// NodeId reservado para la raíz sintética del árbol. El nodo App es siempre
/// el padre lógico de todos los `MountedNode` que producimos.
pub const ROOT_NODE_ID: NodeId = NodeId(1);
/// Offset desde el cual numeramos los `MountedNode`. Deja el rango [0, OFFSET)
/// para ids reservados (root y futuros nodos sintéticos como overlays).
const MOUNTED_OFFSET: u64 = 1000;
/// `NodeId` AccessKit asignado al `MountedNode` con índice `idx`. La función
/// es inversa de [`mounted_idx_for`].
pub fn node_id_for(idx: usize) -> NodeId {
NodeId(MOUNTED_OFFSET + idx as u64)
}
/// Recupera el índice del `MountedNode` que corresponde a un `NodeId`
/// AccessKit, o `None` si el id está fuera del rango de nodos montados.
pub fn mounted_idx_for(id: NodeId) -> Option<usize> {
let v = id.0;
if v >= MOUNTED_OFFSET {
Some((v - MOUNTED_OFFSET) as usize)
} else {
None
}
}
/// Construye el árbol AccessKit completo para el frame actual.
///
/// `focused_idx` es el índice del `MountedNode` enfocado (si lo hay) —
/// resolvelo desde `state.focused: Option<u64>` mapeando contra el campo
/// `focusable` de cada MountedNode.
pub fn build_tree<Msg>(
mounted: &Mounted<Msg>,
computed: &ComputedLayout,
focused_idx: Option<usize>,
app_name: &str,
tree_id: TreeId,
) -> TreeUpdate {
let mut nodes: Vec<(NodeId, Node)> = Vec::with_capacity(mounted.nodes.len() + 1);
// 1) Raíz sintética: lista los hijos top-level (los nodos cuya posición en
// el array es 0 o queda fuera de cualquier subárbol previo). En la práctica
// el `MountedNode` con índice 0 es la raíz del View — la usamos directo.
let mut root = Node::new(AkRole::Window);
root.set_label(app_name.to_string());
if !mounted.nodes.is_empty() {
root.set_children(vec![node_id_for(0)]);
}
nodes.push((ROOT_NODE_ID, root));
// 2) Un Node AccessKit por cada MountedNode. Hijos = los hijos directos en
// el árbol pre-orden (subtree_end nos da el rango).
for (idx, mn) in mounted.nodes.iter().enumerate() {
let mut node = Node::new(map_role(&mn.semantics, mn));
// Bounds del nodo (rect absoluto del layout). Sin esto el lector no
// sabe dónde está visualmente y la navegación por reading order
// degrada a "como vinieron".
if let Some(r) = computed.get(mn.id) {
node.set_bounds(AkRect {
x0: r.x as f64,
y0: r.y as f64,
x1: (r.x + r.w) as f64,
y1: (r.y + r.h) as f64,
});
}
// Label / value / description. Si la app declaró semantics, mandamos
// esos. Si no, intentamos derivar un label del texto visible — los
// lectores leen igualmente texto sin rol, pero un label explícito es
// más claro.
if let Some(spec) = &mn.semantics {
apply_semantics(&mut node, spec);
// Si declaró rol pero no label y hay texto plano en el nodo,
// caemos al texto: cubre widgets como `app-header` que setean
// `.role(Heading)` sobre un nodo con `text_aligned("Título", …)`
// sin duplicar el string en `.aria_label(...)`.
if spec.label.is_none() {
if let Some(t) = &mn.text {
node.set_label(t.content.clone());
}
}
} else if let Some(t) = &mn.text {
node.set_label(t.content.clone());
}
// Acciones: declaramos las que el adapter va a recibir y ejecutar.
// `Focus` para cualquier nodo enfocable; `Click` para cualquier nodo
// con `on_click`. El handler del runtime las despacha en `act` (ver
// eventloop.rs).
if mn.focusable.is_some() {
node.add_action(Action::Focus);
}
if mn.on_click.is_some() || mn.on_click_at.is_some() {
node.add_action(Action::Click);
}
// Hijos: rango [idx+1, subtree_end) — pero acá necesitamos sólo los
// hijos DIRECTOS, no descendientes. Los hijos directos son los nodos
// cuyo padre es este: en el orden pre-orden con `subtree_end`, los
// hijos directos del nodo idx son los nodos h tales que h.parent == idx.
// Lo computamos: empezamos desde idx+1 y saltamos por subtree_end de
// cada hijo, hasta salir del rango.
let children = direct_children(mounted, idx);
if !children.is_empty() {
node.set_children(children.into_iter().map(node_id_for).collect::<Vec<_>>());
}
nodes.push((node_id_for(idx), node));
}
let focus = focused_idx
.map(node_id_for)
.unwrap_or(ROOT_NODE_ID);
TreeUpdate {
nodes,
tree: Some(Tree::new(ROOT_NODE_ID)),
focus,
tree_id,
}
}
/// Índices de los hijos directos del MountedNode `parent_idx`. Asume el
/// recorrido pre-orden estándar del `mount`: el primer hijo está en
/// `parent_idx + 1`; los siguientes se obtienen saltando por `subtree_end`.
fn direct_children<Msg>(mounted: &Mounted<Msg>, parent_idx: usize) -> Vec<usize> {
let parent = &mounted.nodes[parent_idx];
let mut out = Vec::new();
let mut cursor = parent_idx + 1;
while cursor < parent.subtree_end {
out.push(cursor);
cursor = mounted.nodes[cursor].subtree_end;
}
out
}
/// Aplica los campos de un `SemanticsSpec` sobre un `accesskit::Node` recién
/// creado (rol ya fijado). Mapea flags ARIA → setters AccessKit.
fn apply_semantics(node: &mut Node, spec: &SemanticsSpec) {
if let Some(label) = &spec.label {
node.set_label(label.to_string());
}
if let Some(desc) = &spec.description {
node.set_description(desc.to_string());
}
if let Some(value) = &spec.value {
node.set_value(value.to_string());
}
// Flags. AccessKit usa `toggled` para checked/pressed (mismo enum); para
// expanded hay `set_expanded(bool)`; disabled = is_disabled flag.
if let Some(checked) = spec.flags.checked.or(spec.flags.pressed) {
node.set_toggled(if checked {
accesskit::Toggled::True
} else {
accesskit::Toggled::False
});
}
if let Some(expanded) = spec.flags.expanded {
node.set_expanded(expanded);
}
if spec.flags.disabled == Some(true) {
node.set_disabled();
}
if spec.flags.readonly == Some(true) {
node.set_read_only();
}
if spec.flags.required == Some(true) {
node.set_required();
}
}
/// Mapea un `Role` de Llimphi a un `accesskit::Role`. Para nodos sin
/// `semantics` declarado, fallback a `Role::GenericContainer` (un grupo
/// transparente que no aporta semántica propia pero permite que la jerarquía
/// se navegue).
fn map_role<Msg>(spec: &Option<SemanticsSpec>, _mn: &llimphi_compositor::MountedNode<Msg>) -> AkRole {
let Some(role) = spec.as_ref().and_then(|s| s.role) else {
return AkRole::GenericContainer;
};
match role {
LRole::Button => AkRole::Button,
LRole::TextInput => AkRole::TextInput,
LRole::Heading => AkRole::Heading,
LRole::Checkbox => AkRole::CheckBox,
LRole::Label => AkRole::Label,
LRole::Link => AkRole::Link,
LRole::MenuItem => AkRole::MenuItem,
LRole::Tab => AkRole::Tab,
LRole::Image => AkRole::Image,
LRole::Slider => AkRole::Slider,
LRole::Group => AkRole::Group,
}
}
#[cfg(test)]
mod tests {
use super::*;
use llimphi_compositor::{mount, Role as LRole, View};
use llimphi_layout::taffy::prelude::length;
use llimphi_layout::taffy::Size;
use llimphi_layout::{LayoutTree, Style};
/// Monta un árbol con un nodo botón + un texto plano. Devuelve mounted +
/// computed contra un viewport razonable.
fn arbol_simple() -> (llimphi_compositor::Mounted<()>, ComputedLayout) {
let boton = View::<()>::new(Style {
size: Size { width: length(80.0_f32), height: length(40.0_f32) },
..Default::default()
})
.role(LRole::Button)
.aria_label("Guardar");
let texto = View::<()>::new(Style::default()).text(
"Hola",
14.0,
llimphi_raster::peniko::Color::WHITE,
);
let raiz = View::<()>::new(Style::default()).children(vec![boton, texto]);
let mut layout = LayoutTree::new();
let mounted = mount(&mut layout, raiz);
let computed = layout
.compute(mounted.root, (1000.0_f32, 1000.0_f32))
.expect("layout");
(mounted, computed)
}
#[test]
fn build_tree_arma_raiz_y_un_node_por_mounted() {
let (m, c) = arbol_simple();
let tree = build_tree(&m, &c, None, "tawasuyu-test", TreeId(uuid::Uuid::nil()));
// root + 3 nodos (raíz View, boton, texto).
assert_eq!(tree.nodes.len(), 1 + m.nodes.len());
assert_eq!(tree.nodes[0].0, ROOT_NODE_ID);
// El segundo Node es el primer MountedNode (raíz del View).
assert_eq!(tree.nodes[1].0, node_id_for(0));
// Foco fallback = root sintético.
assert_eq!(tree.focus, ROOT_NODE_ID);
}
#[test]
fn boton_con_label_se_traduce_a_role_button() {
let (m, c) = arbol_simple();
let tree = build_tree(&m, &c, None, "test", TreeId(uuid::Uuid::nil()));
// El nodo con role=Button debería tener rol Button en accesskit.
let boton_node = tree
.nodes
.iter()
.find(|(_, n)| n.role() == AkRole::Button)
.expect("hay un Button");
assert_eq!(boton_node.1.label().as_deref(), Some("Guardar"));
}
#[test]
fn texto_sin_semantica_se_lee_como_label_del_node_generico() {
let (m, c) = arbol_simple();
let tree = build_tree(&m, &c, None, "test", TreeId(uuid::Uuid::nil()));
// Algún nodo con label "Hola" (el texto plano).
assert!(
tree.nodes
.iter()
.any(|(_, n)| n.label().as_deref() == Some("Hola")),
"el texto plano debería aparecer como label"
);
}
#[test]
fn foco_explicito_se_refleja_en_treeupdate_focus() {
let (m, c) = arbol_simple();
let tree = build_tree(&m, &c, Some(1), "test", TreeId(uuid::Uuid::nil()));
assert_eq!(tree.focus, node_id_for(1));
}
#[test]
fn mounted_idx_for_invierte_node_id_for() {
for i in 0..16 {
let nid = node_id_for(i);
assert_eq!(mounted_idx_for(nid), Some(i));
}
assert_eq!(mounted_idx_for(ROOT_NODE_ID), None);
}
}
File diff suppressed because it is too large Load Diff
+81
View File
@@ -0,0 +1,81 @@
// a11y_rt.rs — Integración con AccessKit en tiempo de ejecución.
// Empuja el árbol de accesibilidad al SO y atiende las acciones del lector
// de pantalla (focus, click). Separado para no contaminar el flujo de input
// con detalles de la API de accesskit_winit.
use super::super::*;
use super::push_a11y_tree;
impl<A: App> Runtime<A> {
/// Recibe un `accesskit_winit::Event` (ruteado vía `EventLoopProxy` como
/// `UserEvent::A11y(...)`) y reacciona:
/// - `InitialTreeRequested`: el lector pidió el árbol inicial → empujamos
/// uno desde `last_render` si lo hay, o pedimos un redraw que lo creará.
/// - `ActionRequested(req)`: el lector quiere ejecutar una acción sobre un
/// `NodeId`. v1 soporta `Action::Focus` (mueve `state.focused` + dispara
/// `App::on_focus`) y `Action::Click` (ejecuta el `on_click` del nodo).
/// - `AccessibilityDeactivated`: nada que hacer; el siguiente paint dejará
/// de construir trees (el `update_if_active` se autoinhibe).
pub(super) fn handle_a11y_event(&mut self, ev: accesskit_winit::Event) {
use accesskit_winit::WindowEvent as AkWinEvent;
let Some(state) = self.state.as_mut() else { return };
match ev.window_event {
AkWinEvent::InitialTreeRequested => {
// Si ya pintamos un frame, ese mounted sirve para el árbol
// inicial. Si no, forzamos un redraw — el path normal llamará
// a `push_a11y_tree::<A>` al final.
if state.last_render.is_some() {
push_a11y_tree::<A>(state);
} else {
state.window.request_redraw();
}
}
AkWinEvent::ActionRequested(req) => {
let Some(idx) = crate::a11y::mounted_idx_for(req.target_node) else {
return;
};
let Some(cache) = state.last_render.as_ref() else {
return;
};
let Some(node) = cache.mounted.nodes.get(idx) else {
return;
};
match req.action {
accesskit::Action::Focus => {
// Si el nodo es focusable, movemos el foco a su id
// opaco; si no, lo limpiamos. La app recibe la
// transición vía `App::on_focus`.
let new_focus = node.focusable;
state.focused = new_focus;
let model = state.model.as_ref().expect("model");
if let Some(msg) = A::on_focus(model, new_focus) {
let m = state.model.take().expect("model");
state.model = Some(A::update(m, msg, &self.handle));
}
state.last_render = None;
state.window.request_redraw();
}
accesskit::Action::Click => {
// Sólo soportamos `on_click` (Msg directo) en v1. Los
// handlers `*_at` necesitan una posición sintética
// coherente que no tenemos — los ignoramos.
if let Some(msg) = node.on_click.clone() {
let m = state.model.take().expect("model");
state.model = Some(A::update(m, msg, &self.handle));
state.last_render = None;
state.window.request_redraw();
}
}
_ => {
// Otras acciones (Expand/Collapse/Increment/Decrement/
// SetValue/ScrollIntoView/etc.) se sumarán cuando un
// widget concreto lo pida — el modelo `SemanticsSpec`
// ya tiene los flags relevantes; solo falta cablear el
// efecto inverso (acción → mutación de Model).
}
}
}
AkWinEvent::AccessibilityDeactivated => {}
}
}
}
+305
View File
@@ -0,0 +1,305 @@
// helpers.rs — Funciones puras auxiliares del bucle Elm.
// Todas sin efecto observable sobre el runtime; se testean fácil de forma aislada.
use super::super::*;
/// Mapea el [`Cursor`](llimphi_compositor::Cursor) llimphi-native (resuelto por
/// el hit-test de hover) a `winit::window::CursorIcon`. `None` → flecha default.
/// Mantiene al compositor winit-free: la traducción vive sólo en el runtime.
pub(super) fn to_winit_cursor(c: Option<llimphi_compositor::Cursor>) -> llimphi_hal::winit::window::CursorIcon {
use llimphi_compositor::Cursor as C;
use llimphi_hal::winit::window::CursorIcon as I;
match c {
None | Some(C::Default) => I::Default,
Some(C::Pointer) => I::Pointer,
Some(C::Text) => I::Text,
Some(C::Crosshair) => I::Crosshair,
Some(C::Move) => I::Move,
Some(C::Grab) => I::Grab,
Some(C::Grabbing) => I::Grabbing,
Some(C::NotAllowed) => I::NotAllowed,
Some(C::Wait) => I::Wait,
Some(C::Progress) => I::Progress,
Some(C::Help) => I::Help,
Some(C::ColResize) => I::ColResize,
Some(C::RowResize) => I::RowResize,
Some(C::EwResize) => I::EwResize,
Some(C::NsResize) => I::NsResize,
Some(C::NeswResize) => I::NeswResize,
Some(C::NwseResize) => I::NwseResize,
Some(C::ZoomIn) => I::ZoomIn,
Some(C::ZoomOut) => I::ZoomOut,
}
}
/// Resuelve el handler de **escala** (pinch-to-zoom) bajo el punto `(x, y)`
/// contra el cache del último frame (overlay con prioridad, igual que clicks).
/// Devuelve `(handler, focal_x, focal_y)` con el punto focal ya en coordenadas
/// **locales** al rect del nodo. `None` si no hay nodo `on_scale` bajo el
/// cursor. Compartido por el camino Ctrl+rueda y el de `PinchGesture`.
pub(super) fn scale_hit_from_cache<Msg: Clone>(
cache: &RenderCache<Msg>,
x: f32,
y: f32,
) -> Option<(ScaleFn<Msg>, f32, f32)> {
let (m, c) = match cache.overlay.as_ref() {
Some(ov) => (&ov.mounted, &ov.computed),
None => (&cache.mounted, &cache.computed),
};
hit_test_scale(m, c, x, y).and_then(|i| {
let node = &m.nodes[i];
node.on_scale.clone().map(|h| {
let (fx, fy) = c
.get(node.id)
.map(|r| (x - r.x, y - r.y))
.unwrap_or((0.0, 0.0));
(h, fx, fy)
})
})
}
/// Resuelve el handler de **rotación** (trackpad) bajo `(x, y)` contra el
/// cache del último frame (overlay con prioridad). Espejo de
/// [`scale_hit_from_cache`]. Devuelve `(handler, focal_x, focal_y)` con el
/// punto focal local al rect del nodo. `None` si no hay nodo `on_rotate`.
pub(super) fn rotate_hit_from_cache<Msg: Clone>(
cache: &RenderCache<Msg>,
x: f32,
y: f32,
) -> Option<(RotateFn<Msg>, f32, f32)> {
let (m, c) = match cache.overlay.as_ref() {
Some(ov) => (&ov.mounted, &ov.computed),
None => (&cache.mounted, &cache.computed),
};
hit_test_rotate(m, c, x, y).and_then(|i| {
let node = &m.nodes[i];
node.on_rotate.clone().map(|h| {
let (fx, fy) = c
.get(node.id)
.map(|r| (x - r.x, y - r.y))
.unwrap_or((0.0, 0.0));
(h, fx, fy)
})
})
}
/// Resuelve el handler de **doble-tap** bajo `(x, y)` contra el cache del
/// último frame (overlay con prioridad). Elige la variante `_at` (con focal
/// local) si está, o el `Msg` directo. `None` si no hay nodo con doble-tap.
pub(super) fn double_tap_hit_from_cache<Msg: Clone>(
cache: &RenderCache<Msg>,
x: f32,
y: f32,
) -> Option<GestureResolved<Msg>> {
let (m, c) = match cache.overlay.as_ref() {
Some(ov) => (&ov.mounted, &ov.computed),
None => (&cache.mounted, &cache.computed),
};
hit_test_double_tap(m, c, x, y).and_then(|i| {
let node = &m.nodes[i];
let (rx, ry, rw, rh) = c.get(node.id).map(|r| (r.x, r.y, r.w, r.h)).unwrap_or_default();
if let Some(h) = node.on_double_tap_at.clone() {
Some(GestureResolved::At(h, x - rx, y - ry, rw, rh))
} else {
node.on_double_tap.clone().map(GestureResolved::Direct)
}
})
}
/// Como [`double_tap_hit_from_cache`] pero para **long-press**.
pub(super) fn long_press_hit_from_cache<Msg: Clone>(
cache: &RenderCache<Msg>,
x: f32,
y: f32,
) -> Option<GestureResolved<Msg>> {
let (m, c) = match cache.overlay.as_ref() {
Some(ov) => (&ov.mounted, &ov.computed),
None => (&cache.mounted, &cache.computed),
};
hit_test_long_press(m, c, x, y).and_then(|i| {
let node = &m.nodes[i];
let (rx, ry, rw, rh) = c.get(node.id).map(|r| (r.x, r.y, r.w, r.h)).unwrap_or_default();
if let Some(h) = node.on_long_press_at.clone() {
Some(GestureResolved::At(h, x - rx, y - ry, rw, rh))
} else {
node.on_long_press.clone().map(GestureResolved::Direct)
}
})
}
/// Resuelve el **ripple** bajo `(x, y)` contra el cache del último frame
/// (overlay con prioridad). Devuelve `(Ripple, lx, ly)`: la config de la onda
/// + el punto del tap relativo al rect del nodo. `None` si no hay nodo ripple.
pub(super) fn ripple_hit_from_cache<Msg: Clone>(
cache: &RenderCache<Msg>,
x: f32,
y: f32,
) -> Option<(llimphi_compositor::Ripple, f32, f32)> {
let (m, c) = match cache.overlay.as_ref() {
Some(ov) => (&ov.mounted, &ov.computed),
None => (&cache.mounted, &cache.computed),
};
hit_test_ripple(m, c, x, y).and_then(|i| {
let node = &m.nodes[i];
node.ripple.map(|rp| {
let (rx, ry) = c.get(node.id).map(|r| (r.x, r.y)).unwrap_or_default();
(rp, x - rx, y - ry)
})
})
}
// ── Selección de texto fuera del editor (ver `View::selectable`) ──
/// Rect absoluto de un nodo: `(x, y, w, h)`.
pub(super) type AbsRect = (f32, f32, f32, f32);
/// `true` si el `TextSpec` es de texto **uniforme** (sin `runs`/`spans`): los
/// únicos que la selección fuera-del-editor soporta. Los multicolor/RichText
/// son del editor y se ignoran.
pub(super) fn spec_is_uniform(spec: &llimphi_compositor::TextSpec) -> bool {
spec.runs.is_none() && spec.spans.is_none()
}
/// Bajo `(x, y)`, el nodo de texto seleccionable más al frente: su key, su
/// `TextSpec` clonado y su rect absoluto. `None` si no hay texto seleccionable
/// uniforme ahí.
pub(super) fn selectable_hit_from_cache<Msg: Clone>(
cache: &RenderCache<Msg>,
x: f32,
y: f32,
) -> Option<(u64, llimphi_compositor::TextSpec, AbsRect)> {
let (m, c) = match cache.overlay.as_ref() {
Some(ov) => (&ov.mounted, &ov.computed),
None => (&cache.mounted, &cache.computed),
};
let i = hit_test_selectable(m, c, x, y)?;
let node = &m.nodes[i];
let key = node.text_select_key?;
let spec = node.text.as_ref()?;
if !spec_is_uniform(spec) {
return None;
}
let r = c.get(node.id)?;
Some((key, spec.clone(), (r.x, r.y, r.w, r.h)))
}
/// Busca el nodo seleccionable por su `key` estable (para extender el drag o
/// pintar el resaltado en frames posteriores, cuando el `NodeId` ya cambió).
/// Recorre el overlay y el árbol principal. `None` si la key ya no está.
pub(super) fn selectable_by_key<Msg>(
cache: &RenderCache<Msg>,
key: u64,
) -> Option<(llimphi_compositor::TextSpec, AbsRect)> {
let trees = [
cache.overlay.as_ref().map(|ov| (&ov.mounted, &ov.computed)),
Some((&cache.mounted, &cache.computed)),
];
trees
.into_iter()
.flatten()
.find_map(|(m, c)| selectable_node_in(m, c, key))
}
/// Busca en un árbol montado concreto el nodo de texto seleccionable con esa
/// `key` y devuelve su `TextSpec` clonado + rect. Lo usa tanto la búsqueda por
/// cache como el pintado del resaltado en el redraw (que tiene el `Mounted`
/// del frame a mano, no un `RenderCache`).
pub(super) fn selectable_node_in<Msg>(
m: &Mounted<Msg>,
c: &ComputedLayout,
key: u64,
) -> Option<(llimphi_compositor::TextSpec, AbsRect)> {
for node in &m.nodes {
if node.text_select_key == Some(key) {
let spec = node.text.as_ref()?;
if !spec_is_uniform(spec) {
return None;
}
let r = c.get(node.id)?;
return Some((spec.clone(), (r.x, r.y, r.w, r.h)));
}
}
None
}
/// Reconstruye el `parley::Layout` de un nodo de texto, idéntico al que pinta
/// el render (misma ruta cacheada `Typesetter::layout`), para hit-testear y
/// medir la selección. El ancho de wrap es el del rect del nodo.
pub(super) fn build_selectable_layout(
ts: &mut llimphi_text::Typesetter,
spec: &llimphi_compositor::TextSpec,
width: f32,
) -> llimphi_text::parley::Layout<()> {
ts.layout(
&spec.content,
spec.size_px,
Some(width),
spec.alignment,
spec.line_height,
spec.italic,
spec.font_family.as_deref(),
spec.weight,
spec.underline,
spec.strikethrough,
spec.letter_spacing,
spec.word_spacing,
)
}
/// `true` si la tecla lógica es el carácter `c` (case-insensitive). Para
/// atajos como Ctrl+C sin acoplarse a mayúsculas/minúsculas ni layout.
pub(super) fn key_is_char(key: &Key, c: char) -> bool {
matches!(
key,
Key::Character(s) if s.chars().next().map(|k| k.eq_ignore_ascii_case(&c)).unwrap_or(false)
)
}
/// Copia texto al portapapeles del sistema (best-effort). Con la feature
/// `clipboard` usa `arboard`; sin backend (headless) o sin la feature es no-op
/// silencioso — nunca panica.
#[cfg(feature = "clipboard")]
pub(super) fn copy_to_clipboard(text: &str) {
if let Ok(mut cb) = arboard::Clipboard::new() {
let _ = cb.set_text(text.to_string());
}
}
#[cfg(not(feature = "clipboard"))]
pub(super) fn copy_to_clipboard(_text: &str) {}
/// Resuelve los [`View::layout_builder`] del árbol de la app en dos pasadas
/// (ver [`llimphi_compositor::expand_layout_builders`]). **Coste cero** cuando
/// ningún nodo usa el builder: devuelve el `view()` sin tocar tras un walk
/// barato. Cuando hay builders: monta el árbol (builders como hojas), computa
/// para conocer sus slots, y reconstruye un `view()` fresco expandiendo cada
/// builder con sus constraints reales. `viewport` en px físicos; `ts` para medir
/// texto igual que el compute principal. Lo llaman el redraw (vía cache) y el
/// fallback de press.
pub(super) fn resolve_layout_builders<A: App>(
model: &A::Model,
viewport: (f32, f32),
ts: &mut llimphi_text::Typesetter,
) -> View<A::Msg> {
let view = A::view(model);
if !has_layout_builder(&view) {
return view;
}
// Pasada 1: montar (builders = hojas con su Style) y computar el layout.
let mut l1 = LayoutTree::new();
let m1: Mounted<A::Msg> = mount(&mut l1, view);
let c1 = {
let tmap = &m1.text_measures;
l1.compute_with_measure(m1.root, viewport, |nid, known, avail| {
match tmap.get(&nid) {
Some(tm) => measure_text_node(ts, tm, known, avail),
None => llimphi_layout::taffy::Size::ZERO,
}
})
.expect("layout layout_builder pasada 1")
};
let cons = collect_builder_constraints(&m1, &c1);
// Pasada 2: árbol fresco (mismo Model → misma estructura, mismo pre-orden de
// builders) + expand con las constraints resueltas.
expand_layout_builders(A::view(model), &cons)
}
File diff suppressed because it is too large Load Diff
+218
View File
@@ -0,0 +1,218 @@
// eventloop/mod.rs — Bucle Elm sobre winit: núcleo del runtime llimphi-ui.
//
// Este módulo actúa como organizador: declara los submódulos por responsabilidad
// e implementa los tres puntos de entrada del `ApplicationHandler` de winit
// (`resumed`, `user_event`, `about_to_wait`). El handler de `window_event`
// delega a `input` (primaria) o `secondary` según el `WindowId`.
//
// Submódulos:
// - helpers — funciones puras (hit-test helpers, selección, layout builders)
// - input — manejo de todos los WindowEvent de la ventana primaria
// - redraw — ciclo mount → layout → paint → GPU → present
// - secondary — gestión de ventanas OS secundarias (opt-in)
// - a11y_rt — integración AccessKit en tiempo de ejecución
mod a11y_rt;
mod helpers;
mod input;
mod redraw;
mod secondary;
use super::*;
pub(crate) fn build_window_attributes<A: App>() -> WindowAttributes {
let (w, h) = A::initial_size();
let attrs = WindowAttributes::default()
.with_title(A::title())
.with_inner_size(LogicalSize::new(w, h));
// En Linux, `with_name` del trait de Wayland mapea al `app_id` del
// xdg-toplevel — lo que el compositor (`mirada-compositor`) usa para
// reconocer ventanas especiales (greeter, launcher…).
#[cfg(all(target_os = "linux", not(target_os = "android")))]
{
if let Some(id) = A::app_id() {
use llimphi_hal::winit::platform::wayland::WindowAttributesExtWayland;
return attrs.with_name(id, "");
}
}
attrs
}
/// Empuja al adapter AccessKit el árbol del último frame pintado. Llamar tras
/// guardar `state.last_render`. `update_if_active` no construye el árbol si no
/// hay tecnología asistiva activa (coste cero en ese caso). Pública sólo
/// dentro del crate; las tests no la necesitan.
fn push_a11y_tree<A: App>(state: &mut RuntimeState<A>) {
let Some(cache) = state.last_render.as_ref() else {
return;
};
// El foco que tenemos es un id opaco u64 (`focusable`); el árbol AccessKit
// necesita el índice del MountedNode. Resolvemos buscando.
let focused_idx = state.focused.and_then(|fid| {
cache
.mounted
.nodes
.iter()
.position(|n| n.focusable == Some(fid))
});
let app_name = A::window_title(state.model.as_ref().expect("model"))
.unwrap_or_else(|| String::from("Llimphi"));
let tree_id = state.a11y_tree_id;
state.a11y_adapter.update_if_active(|| {
crate::a11y::build_tree(&cache.mounted, &cache.computed, focused_idx, &app_name, tree_id)
});
}
impl<A: App> ApplicationHandler<UserEvent<A::Msg>> for Runtime<A> {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.state.is_some() {
return;
}
let window = event_loop
.create_window(build_window_attributes::<A>())
.expect("create window");
let window = Arc::new(window);
// IME opt-in: sólo se habilita si la app lo pide (ver `App::ime_allowed`).
// Con IME activo el texto compuesto llega por `WindowEvent::Ime`.
if A::ime_allowed() {
window.set_ime_allowed(true);
}
// Adapter AccessKit: lo creamos ANTES del primer redraw, conectado al
// EventLoopProxy del runtime. El adapter emitirá `accesskit_winit::Event`
// (Initial tree requested, ActionRequested, deactivated) — nuestro
// `From<accesskit_winit::Event> for UserEvent<Msg>` los rutea como
// `UserEvent::A11y(...)` para que entren por el mismo `user_event`.
let a11y_proxy: EventLoopProxy<UserEvent<A::Msg>> =
match &self.handle.inner {
HandleInner::Real(p) => p.clone(),
HandleInner::Test => unreachable!("resumed sin event loop real"),
HandleInner::Lifted(_) => unreachable!("el runtime nunca corre con un handle lifteado"),
};
let a11y_adapter =
accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, a11y_proxy);
let a11y_tree_id = accesskit::TreeId(uuid::Uuid::new_v4());
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let surface = WinitSurface::new(&hal, window.clone()).expect("surface");
let renderer = Renderer::new(&hal).expect("renderer");
let overlay_compositor = llimphi_hal::OverlayCompositor::new(&hal.device);
let blur_compositor = llimphi_hal::BlurCompositor::new(&hal.device);
let color_filter_compositor = llimphi_hal::ColorFilterCompositor::new(&hal.device);
let typesetter = llimphi_text::Typesetter::new();
window.request_redraw();
self.state = Some(RuntimeState {
window,
hal,
surface,
renderer,
scene: vello::Scene::new(),
overlay_compositor,
blur_compositor,
color_filter_compositor,
model: Some(A::init(&self.handle)),
cursor: PhysicalPosition::new(0.0, 0.0),
modifiers: Modifiers::default(),
typesetter,
layout: LayoutTree::new(),
overlay_layout: LayoutTree::new(),
last_render: None,
hovered: None,
drag: None,
focused: None,
last_title: None,
anim_registry: llimphi_compositor::AnimRegistry::new(),
size_anim_registry: llimphi_compositor::SizeAnimRegistry::new(),
hero_registry: llimphi_compositor::HeroRegistry::new(),
ripple_registry: llimphi_compositor::RippleRegistry::new(),
last_tap: None,
pending_long_press: None,
retained: None,
selection: None,
a11y_adapter,
a11y_tree_id,
});
// Sincroniza el factor de escala inicial (el de la ventana recién
// creada) ANTES del primer render: así una app que dependa del DPI
// (p. ej. `devicePixelRatio` en puriy) ya lo tiene correcto en su
// primera pasada, sin esperar a un ScaleFactorChanged.
if let Some(state) = self.state.as_mut() {
let scale = state.window.scale_factor();
if let Some(msg) = A::on_scale_factor(state.model.as_ref().expect("model"), scale) {
let model = state.model.take().expect("model");
state.model = Some(A::update(model, msg, &self.handle));
state.last_render = None;
}
}
}
fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent<A::Msg>) {
match event {
UserEvent::Quit => event_loop.exit(),
UserEvent::Msg(msg) => {
// Un Msg del canal (Handle::dispatch, ticks periódicos, trabajo
// de fondo) muta el modelo compartido y repinta TODAS las
// ventanas — así un cambio se refleja tanto en la primaria como
// en las secundarias (config) sin importar de dónde vino.
self.dispatch_model(msg);
}
UserEvent::OpenWindow { key, title, width, height } => {
self.open_secondary(event_loop, key, title, width, height);
}
UserEvent::CloseWindow { key } => {
if let Some(pos) = self.secondaries.iter().position(|s| s.key == key) {
// Drop de la SecondaryState → se destruye la ventana/surface.
self.secondaries.remove(pos);
}
}
UserEvent::A11y(ev) => {
self.handle_a11y_event(ev);
}
}
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_id: WindowId,
event: WindowEvent,
) {
// ¿El evento es de una ventana secundaria? Lo atiende su handler
// dedicado (path aparte: la primaria queda 100% intacta).
if let Some(idx) = self.secondaries.iter().position(|s| s.window.id() == _id) {
self.handle_secondary_event(idx, event);
return;
}
self.handle_primary_window_event(event_loop, event);
}
/// Se ejecuta tras procesar los eventos de cada vuelta, justo antes de que
/// el loop se duerma. Es donde vence el **long-press**: si hay uno armado y
/// ya pasó su `deadline` (el botón siguió apretado y quieto), se dispara su
/// `Msg`. Mientras quede uno pendiente, ponemos `WaitUntil(deadline)` para
/// que winit nos despierte a tiempo (con `ControlFlow::Wait` el loop dormiría
/// indefinidamente sin un evento que lo despierte). Sin long-press armado,
/// volvemos a `Wait` (no dejar un `WaitUntil` viejo: con un deadline pasado
/// el loop spinearía). Las animaciones implícitas no usan el control flow
/// (piden frames con `request_redraw`), así que esto no las afecta.
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
let Some(state) = self.state.as_mut() else {
return;
};
match state.pending_long_press.as_ref() {
Some(p) => {
if std::time::Instant::now() >= p.deadline {
let handler = state.pending_long_press.take().expect("pending").handler;
event_loop.set_control_flow(ControlFlow::Wait);
if let Some(msg) = handler.invoke() {
let model = state.model.take().expect("model");
state.model = Some(A::update(model, msg, &self.handle));
state.last_render = None;
state.window.request_redraw();
}
} else {
event_loop.set_control_flow(ControlFlow::WaitUntil(p.deadline));
}
}
None => event_loop.set_control_flow(ControlFlow::Wait),
}
}
}
+574
View File
@@ -0,0 +1,574 @@
// redraw.rs — Ciclo completo de repintado: layout → paint → GPU → present.
// Esta función es la ruta caliente: mount + compute + animaciones + vello +
// pasadas GPU + present + retención de frame.
use super::super::*;
use super::helpers::{
build_selectable_layout, resolve_layout_builders, selectable_node_in,
};
use super::push_a11y_tree;
/// Ejecuta la pasada completa de redraw para la ventana primaria.
/// Se llama desde `handle_primary_window_event` cuando el evento es
/// `WindowEvent::RedrawRequested`. Recibe `state` y `handle` separados para
/// facilitar el borrow-checker (no necesita `&mut Runtime<A>` completo).
pub(super) fn handle_redraw<A: App>(
state: &mut RuntimeState<A>,
handle: &Handle<A::Msg>,
) {
// **Retención de frame entero**. Si:
// (a) hay scene retenida del frame anterior (`retained`),
// (b) `last_render` SIGUE siendo `Some` — la invariante del
// runtime es que cualquier handler que muta visualmente
// pone `last_render = None`, así que `Some` ⇒ nadie tocó
// nada que afecte la pintura,
// (c) el frame retenido NO estaba animando ni ripplando
// (si lo estaba, el ticker NECESITA avanzarlo),
// (d) no hay overlay, drag, ni long-press en curso (camino
// conservador: esos casos suelen estar acoplados a
// cambios visuales que no atraviesan `last_render`),
// (e) el viewport sigue del mismo tamaño,
// entonces `state.scene` ya tiene EXACTAMENTE lo que hay que
// mostrar. Saltamos mount + layout + paint y solo hacemos un
// render+present de la scene retenida. Cubre redraws espurios
// (expose del compositor, refocus, el último frame de una anim
// ya asentada). Si algo falla en el acquire, caemos al camino
// completo (no es un error, sólo un viewport efímero).
let cache_hit = state.last_render.is_some()
&& state.drag.is_none()
&& state.pending_long_press.is_none()
&& state.retained.as_ref().is_some_and(|r| {
!r.animating
&& !r.rippling
&& !r.has_overlay
&& (r.w, r.h) == state.surface.size()
});
if cache_hit {
match state.surface.acquire() {
Ok(frame) => {
if state
.renderer
.render(&state.hal, &state.scene, &frame, palette::css::BLACK)
.is_ok()
{
state.surface.present(frame, &state.hal);
return;
}
// render falló → cae al camino completo
}
Err(_) => { /* surface efímera → camino completo */ }
}
}
// Título dinámico (App::window_title): si cambió respecto del
// último aplicado, se lo pasamos a winit. Barato: una
// comparación de String por frame, set_title sólo en el cambio.
if let Some(t) = A::window_title(state.model.as_ref().expect("model")) {
if state.last_title.as_deref() != Some(t.as_str()) {
state.window.set_title(&t);
state.last_title = Some(t);
}
}
// Posicioná la ventana de candidatos del IME junto al caret
// (sólo con IME activo y si la app reporta el área).
if A::ime_allowed() {
if let Some((x, y, w, h)) =
A::ime_cursor_area(state.model.as_ref().expect("model"))
{
state.window.set_ime_cursor_area(
llimphi_hal::winit::dpi::PhysicalPosition::new(x as f64, y as f64),
llimphi_hal::winit::dpi::PhysicalSize::new(
w.max(1.0) as u32,
h.max(1.0) as u32,
),
);
}
}
let frame = match state.surface.acquire() {
Ok(f) => f,
Err(_) => {
let (w, h) = state.surface.size();
state.surface.resize(w, h);
state.window.request_redraw();
return;
}
};
let (w, h) = frame.size();
// LayoutBuilder: resuelve los constructores diferidos en dos
// pasadas (coste cero si no hay ninguno). Necesita el typesetter
// para medir, así que va antes de tomar `model_ref` para el overlay.
let mut view = resolve_layout_builders::<A>(
state.model.as_ref().expect("model"),
(w as f32, h as f32),
&mut state.typesetter,
);
// Animaciones implícitas de **tamaño** (`View::animated_size`):
// reconcila el `View` tree y parcha `style.size` ANTES del
// mount/layout. Así siblings/hijos reflowean suave (la
// animación se ve en el layout cascade, no sólo en el rect del
// nodo aislado). Coste cero sin nodos `animated_size`.
let frame_now = std::time::Instant::now();
let size_animating = llimphi_compositor::reconcile_size_anim(
&mut view,
&mut state.size_anim_registry,
frame_now,
);
let model_ref = state.model.as_ref().expect("model");
let overlay_view = A::view_overlay(model_ref);
// Reusamos los árboles de layout del runtime: `clear()` +
// `mount` evita re-allocar el slotmap de taffy por frame.
state.layout.clear();
let mut mounted: Mounted<A::Msg> = mount(&mut state.layout, view);
let computed = {
let ts = &mut state.typesetter;
let tmap = &mounted.text_measures;
state
.layout
.compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| {
match tmap.get(&nid) {
Some(tm) => measure_text_node(ts, tm, known, avail),
None => llimphi_layout::taffy::Size::ZERO,
}
})
.expect("layout")
};
// Animaciones implícitas (`View::animated`): reconcilia el árbol
// con el estado retenido DESPUÉS del layout y ANTES del paint —
// interpola fill/radius de los nodos con `anim`. Si alguna sigue
// viva pedimos otro frame al final (ticker autodetenido).
let now = frame_now;
let anim_active = state.anim_registry.reconcile(&mut mounted, now);
// Heroes (`View::hero`): si la misma key cambió de rect entre
// frames, escribe en `transform` la afín que "vuela" del rect
// anterior al actual. Independiente del anim_registry — sólo
// toca `transform`, que el paint ya respeta. Coste cero sin
// nodos hero.
let hero_active = state.hero_registry.reconcile(&mut mounted, &computed, now);
// `size_animating` viene del reconcile previo al mount; lo
// ORrijimos al `animating` global para que se pida el
// próximo frame y el `retained.animating == true` invalide
// la cache de retención (la siguiente pasada reconstruye con
// el size interpolado).
let animating = anim_active || hero_active || size_animating;
// Mount + layout del overlay en un árbol aparte. Lo
// computamos con el mismo tamaño de viewport para que
// un scrim a percent(1.0) cubra toda la pantalla.
let overlay_built = if let Some(v) = overlay_view {
state.overlay_layout.clear();
let omounted: Mounted<A::Msg> = mount(&mut state.overlay_layout, v);
let ocomputed = {
let ts = &mut state.typesetter;
let tmap = &omounted.text_measures;
state
.overlay_layout
.compute_with_measure(omounted.root, (w as f32, h as f32), |nid, known, avail| {
match tmap.get(&nid) {
Some(tm) => measure_text_node(ts, tm, known, avail),
None => llimphi_layout::taffy::Size::ZERO,
}
})
.expect("layout overlay")
};
let ohover = hit_test_hover(
&omounted,
&ocomputed,
state.cursor.x as f32,
state.cursor.y as f32,
);
Some(OverlayCache {
mounted: omounted,
computed: ocomputed,
hover_idx: ohover,
})
} else {
None
};
// Hover en el main solo si NO hay overlay — durante un
// menú abierto, el fondo no debe reaccionar al ratón.
let hover_idx = if overlay_built.is_some() {
None
} else {
hit_test_hover(
&mounted,
&computed,
state.cursor.x as f32,
state.cursor.y as f32,
)
};
// Drop hover sólo si hay drag activo con payload (un
// drag bloquea el overlay; rara combinación pero la
// resolvemos a favor del drag).
let drop_hover_idx = state
.drag
.as_ref()
.and_then(|d| d.payload.map(|_| ()))
.and_then(|_| {
hit_test_drop(
&mounted,
&computed,
state.cursor.x as f32,
state.cursor.y as f32,
)
});
// Z-order del overlay sobre contenido `gpu_paint`: si el
// árbol principal tiene painters gpu (p. ej. el video de
// media) Y hay un overlay activo, el overlay NO va en la
// escena principal (quedaría debajo del blit gpu). Se
// rasteriza aparte sobre fondo transparente y se compone con
// alpha DESPUÉS del pase gpu. Sin gpu o sin overlay, el camino
// de siempre (overlay en la escena principal) — coste cero.
let composite_overlay =
overlay_built.is_some() && has_gpu_painter(&mounted);
state.scene.reset();
paint(
&mut state.scene,
&mounted,
&computed,
&mut state.typesetter,
hover_idx,
drop_hover_idx,
);
// Animación de salida (fade-out). 1) Capturá la subescena de
// cada nodo `exit` presente (snapshot para cuando desaparezca).
// 2) Reproducí los fantasmas de los que ya se fueron, con
// opacidad decreciente — por encima del contenido, debajo del
// overlay. Coste cero si ningún nodo usa `animated_exit`.
for (idx, end, key) in state.anim_registry.live_exit_nodes(&mounted) {
let (dur, easing) = {
let a = mounted.nodes[idx].anim.expect("nodo exit lleva anim");
(a.duration, a.easing)
};
let mut sub = vello::Scene::new();
paint_range(
&mut sub,
&mounted,
&computed,
&mut state.typesetter,
None,
None,
idx,
end,
vello::kurbo::Affine::IDENTITY,
);
state.anim_registry.store_live_exit(key, sub, dur, easing);
}
state
.anim_registry
.replay_ghosts(&mut state.scene, now, w as f32, h as f32);
// Resaltado de la selección de texto activa (sobre el
// contenido, bajo el overlay). Reconstruye el layout del nodo
// seleccionado y pinta los rects de `parley::Selection` con un
// tinte translúcido (deja leer el texto debajo).
if let Some(tsel) = state.selection {
if let Some((spec, (rx, ry, rw, _rh))) =
selectable_node_in(&mounted, &computed, tsel.key)
{
let layout = build_selectable_layout(&mut state.typesetter, &spec, rw);
use vello::kurbo::{Affine, Rect};
use vello::peniko::{Color, Fill};
let hl = Color::from_rgba8(86, 148, 246, 80);
let scene = &mut state.scene;
tsel.sel.geometry_with(&layout, |bb, _line| {
let r = Rect::new(
rx as f64 + bb.x0,
ry as f64 + bb.y0,
rx as f64 + bb.x1,
ry as f64 + bb.y1,
);
scene.fill(Fill::NonZero, Affine::IDENTITY, hl, None, &r);
});
}
}
// Ripples/InkWell: las salpicaduras vivas se pintan sobre el
// contenido (translúcidas, recortadas al nodo) y debajo del
// overlay. Si alguna sigue viva, pide otro frame al final.
let rippling =
state
.ripple_registry
.paint(&mut state.scene, &mounted, &computed, now);
if !composite_overlay {
if let Some(ov) = overlay_built.as_ref() {
paint(
&mut state.scene,
&ov.mounted,
&ov.computed,
&mut state.typesetter,
ov.hover_idx,
None,
);
}
}
if let Err(e) = state.renderer.render(
&state.hal,
&state.scene,
&frame,
palette::css::BLACK,
) {
eprintln!("render error: {e}");
}
let (vw, vh) = frame.size();
// Capa de overlay aparte (camino composite): vello la
// rasteriza con fondo transparente en `frame.overlay_view()`.
// Se renderiza ANTES del pase gpu para que el blit del
// compositor (en `gpu_encoder`) la lea ya escrita.
if composite_overlay {
if let Some(ov) = overlay_built.as_ref() {
state.scene.reset();
paint(
&mut state.scene,
&ov.mounted,
&ov.computed,
&mut state.typesetter,
ov.hover_idx,
None,
);
if let Err(e) = state.renderer.render_to_view(
&state.hal,
&state.scene,
frame.overlay_view(),
vw,
vh,
palette::css::TRANSPARENT,
) {
eprintln!("render overlay error: {e}");
}
}
}
// Pasada GPU directo (Fase 1 del SDD §"GPU directo wgpu"):
// si algún View del main o del overlay registró un
// `gpu_painter`, ejecutamos todos sus callbacks contra un
// único `CommandEncoder`, encima de lo que vello acaba de
// pintar sobre la intermediate. Submitimos antes del
// present para que el blit al swapchain incluya las
// primitivas GPU. Si nadie usó el hook, no se crea ni
// submitea nada — coste cero.
let mut gpu_encoder = state.hal.device.create_command_encoder(
&llimphi_hal::wgpu::CommandEncoderDescriptor {
label: Some("llimphi-ui-gpu-paint"),
},
);
let viewport = frame.size();
// Backdrop blur (Bloque 11): post-pasada Gauss separable sobre
// la intermediate, restringida al rect de cada nodo
// `.backdrop_blur(sigma)`. Sucede TRAS la rasterización vello
// y ANTES de los `gpu_painter`/composite — los painters GPU
// que se solapen con el blur ven el rect ya borroneado y se
// dibujan encima nítidos. Coste cero sin nodos blur (loop
// vacío + bandera `blurred` queda false).
let backdrop_blurs =
llimphi_compositor::collect_backdrop_blurs(&mounted, &computed);
let blurred = !backdrop_blurs.is_empty();
for b in &backdrop_blurs {
state.blur_compositor.blur(
&state.hal.device,
&state.hal.queue,
&mut gpu_encoder,
frame.view(),
viewport,
b.rect,
b.sigma,
);
}
// `filter: …` sobre el propio subárbol (Fase 7.1232+): misma post-pasada
// que el backdrop, pero leyendo `MountedNode::filter` y restringida al rect
// del nodo. Se aplica DESPUÉS del backdrop, sobre los píxeles ya
// rasterizados (el contenido del nodo). Hoy sólo `Blur`; el resto de las
// variantes se suman por fase.
let filter_passes = llimphi_compositor::collect_filters(&mounted, &computed);
let filtered = !filter_passes.is_empty();
for p in &filter_passes {
match &p.op {
llimphi_compositor::FilterOp::Blur(sigma) => {
state.blur_compositor.blur(
&state.hal.device,
&state.hal.queue,
&mut gpu_encoder,
frame.view(),
viewport,
p.rect,
*sigma,
);
}
llimphi_compositor::FilterOp::ColorMatrix(m) => {
state.color_filter_compositor.apply(
&state.hal.device,
&state.hal.queue,
&mut gpu_encoder,
frame.view(),
viewport,
p.rect,
*m,
);
}
// drop-shadow se pinta en vello (no es post-pasada); collect_filters
// no la emite, pero el match debe ser exhaustivo. Fase 7.1234.
llimphi_compositor::FilterOp::DropShadow(_) => {}
}
}
let mut any_gpu = blurred
| filtered
| paint_gpu(
&mounted,
&computed,
&state.hal.device,
&state.hal.queue,
&mut gpu_encoder,
frame.view(),
viewport,
);
if let Some(ov) = overlay_built.as_ref() {
// En el camino composite, los painters gpu del overlay van
// sobre SU textura; si no, sobre la intermedia.
let target = if composite_overlay {
frame.overlay_view()
} else {
frame.view()
};
any_gpu |= paint_gpu(
&ov.mounted,
&ov.computed,
&state.hal.device,
&state.hal.queue,
&mut gpu_encoder,
target,
viewport,
);
}
// Capa vello "over" (Primitivo B): nodos con `paint_over` registran
// primitivas vello que deben quedar ENCIMA del pase GPU directo
// (sprites/texto AA sobre celdas instanciadas — dominium, motor
// voxel). Camino opt-in y coste cero si nadie la usa (loop barato +
// bandera false): el orden total queda [vello base] → [gpu_paint] →
// [vello over] → [overlay/menús].
//
// Mecánica de z-order correcta: rasterizamos la escena over en una
// textura scratch transparente (vello, con su propio submit — no
// toca la intermedia) y luego GRABAMOS el composite alpha de esa
// scratch sobre la intermedia DENTRO de `gpu_encoder`, después de
// los pases GPU directos. Como `gpu_encoder` se submitea al final
// (línea de abajo), el composite corre en la GPU DESPUÉS de las
// primitivas GPU → el over-layer queda encima de ellas. Va ANTES
// del composite de menús, así los menús siguen por encima del over.
let over_active = has_over_painter(&mounted)
|| overlay_built
.as_ref()
.map(|ov| has_over_painter(&ov.mounted))
.unwrap_or(false);
if over_active {
// Escena vello aparte (no pisamos `state.scene`, que el caller
// retiene/reusa). Fondo transparente: sólo lo pintado por los
// `over_painter` lleva alpha.
let mut over_scene = vello::Scene::new();
let mut any_over = paint_over(
&mut over_scene,
&mounted,
&computed,
&mut state.typesetter,
);
if let Some(ov) = overlay_built.as_ref() {
any_over |= paint_over(
&mut over_scene,
&ov.mounted,
&ov.computed,
&mut state.typesetter,
);
}
if any_over {
// Scratch transparente del tamaño del frame (mismo formato
// que la intermedia: Rgba8Unorm). Vello escribe via compute
// (STORAGE_BINDING) y el composite la lee como sampler
// (TEXTURE_BINDING). Por-frame: sólo se crea cuando hay
// over-layer activo, igual que los buffers de `GpuBatch`.
let over_tex = state.hal.device.create_texture(
&llimphi_hal::wgpu::TextureDescriptor {
label: Some("llimphi-ui-over-scratch"),
size: llimphi_hal::wgpu::Extent3d {
width: vw,
height: vh,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: llimphi_hal::wgpu::TextureDimension::D2,
format: llimphi_hal::wgpu::TextureFormat::Rgba8Unorm,
usage: llimphi_hal::wgpu::TextureUsages::STORAGE_BINDING
| llimphi_hal::wgpu::TextureUsages::TEXTURE_BINDING
| llimphi_hal::wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
},
);
let over_view =
over_tex.create_view(&llimphi_hal::wgpu::TextureViewDescriptor::default());
// Vello rasteriza la escena over a la scratch (su propio
// submit; limpia con TRANSPARENT). Independiente de la
// intermedia.
if let Err(e) = state.renderer.render_to_view(
&state.hal,
&over_scene,
&over_view,
vw,
vh,
palette::css::TRANSPARENT,
) {
eprintln!("render over-layer error: {e}");
}
// Composite alpha de la scratch sobre la intermedia, grabado
// en `gpu_encoder` DESPUÉS de los pases GPU directos.
state.overlay_compositor.composite(
&state.hal.device,
&mut gpu_encoder,
frame.view(),
&over_view,
);
any_gpu = true;
}
}
// Composición alpha del overlay SOBRE la intermedia (que ya
// tiene UI + video). Último pase del encoder → corre después
// del blit del video. Garantiza menús por encima del video.
if composite_overlay {
state.overlay_compositor.composite(
&state.hal.device,
&mut gpu_encoder,
frame.view(),
frame.overlay_view(),
);
any_gpu = true;
}
if any_gpu {
state
.hal
.queue
.submit(std::iter::once(gpu_encoder.finish()));
}
state.surface.present(frame, &state.hal);
// Ticker de animaciones implícitas: si quedó alguna en curso,
// pedí el próximo frame. Cuando todas se asientan, `animating`
// queda false y el loop de redraws se detiene solo (sin render
// ocioso, sin spawn_periodic por animación).
if animating || rippling {
state.window.request_redraw();
}
state.retained = Some(RetainedScene {
w,
h,
animating,
rippling,
has_overlay: overlay_built.is_some(),
});
state.last_render = Some(RenderCache {
mounted,
computed,
hover_idx,
drop_hover_idx,
overlay: overlay_built,
});
// AccessKit: tras un paint exitoso, empujamos el árbol al
// adapter. `update_if_active` se salta el closure si no hay
// tecnología asistiva escuchando — coste cero en ese caso.
push_a11y_tree::<A>(state);
// `handle` se recibe pero el redraw no lo necesita directamente;
// se pasa para mantener la firma consistente con el caller.
let _ = handle;
}
+452
View File
@@ -0,0 +1,452 @@
// secondary.rs — Gestión de ventanas OS secundarias (opt-in, multiventana).
// Path APARTE del de la primaria: comparten modelo (vive en `self.state`) y
// `Hal`/`Renderer`, pero cada secundaria lleva su surface + caches. Sin
// overlay ni foco (la config no los necesita); se puede ampliar luego.
use super::super::*;
impl<A: App> Runtime<A> {
/// Aplica un Msg al modelo (que vive en la primaria) e invalida + repinta
/// TODAS las ventanas. Es el camino de cualquier evento de una secundaria,
/// así un cambio hecho en la config se refleja al toque en el reproductor
/// (y viceversa, vía los ticks que pasan por `user_event`).
pub(super) fn dispatch_model(&mut self, msg: A::Msg) {
if let Some(prim) = self.state.as_mut() {
let model = prim.model.take().expect("model");
prim.model = Some(A::update(model, msg, &self.handle));
prim.last_render = None;
prim.window.request_redraw();
}
// OJO: NO repintamos las secundarias acá. `dispatch_model` corre en
// cada Msg (incluido el tick ~33 fps), y repintar una secundaria por
// tick serializaba dos `acquire()` de swapchain en Wayland FIFO →
// ralentización y cuelgue. Cada secundaria se repinta sola al
// interactuar con ella (`handle_secondary_event` llama
// `render_secondary` tras un cambio) y en su `RedrawRequested` del
// compositor (expose/resize). El modelo igual quedó actualizado, así
// que el próximo repintado de la secundaria refleja el cambio.
}
/// Despacha un Msg y repinta la secundaria `idx` en el acto (si sigue
/// viva). El camino de los eventos de una secundaria: como su
/// `request_redraw` no dispara `RedrawRequested` en algunos compositores,
/// la pintamos directo tras el cambio.
pub(super) fn dispatch_and_render_secondary(&mut self, idx: usize, msg: A::Msg) {
self.dispatch_model(msg);
if idx < self.secondaries.len() {
self.render_secondary(idx);
}
}
/// Crea una ventana OS secundaria (o enfoca la existente con esa key). Toma
/// el `Hal` de la primaria — no levanta un segundo device GPU.
pub(super) fn open_secondary(
&mut self,
event_loop: &llimphi_hal::winit::event_loop::ActiveEventLoop,
key: u64,
title: String,
width: u32,
height: u32,
) {
if let Some(sec) = self.secondaries.iter().find(|s| s.key == key) {
sec.window.focus_window();
return;
}
let Some(prim) = self.state.as_ref() else {
return; // no hay primaria todavía (no debería pasar)
};
let attrs = llimphi_hal::winit::window::WindowAttributes::default()
.with_title(title)
.with_inner_size(llimphi_hal::winit::dpi::LogicalSize::new(width, height));
let window = match event_loop.create_window(attrs) {
Ok(w) => Arc::new(w),
Err(e) => {
eprintln!("open_window: no pude crear la ventana: {e}");
return;
}
};
let surface = match WinitSurface::new(&prim.hal, window.clone()) {
Ok(s) => s,
Err(e) => {
eprintln!("open_window: no pude crear la surface: {e}");
return;
}
};
window.request_redraw();
self.secondaries.push(SecondaryState {
key,
window,
surface,
scene: vello::Scene::new(),
typesetter: llimphi_text::Typesetter::new(),
layout: LayoutTree::new(),
cursor: llimphi_hal::winit::dpi::PhysicalPosition::new(0.0, 0.0),
modifiers: Modifiers::default(),
last_render: None,
hovered: None,
drag: None,
last_title: None,
});
}
/// Pinta la ventana secundaria `idx` con `A::secondary_view`. Reusa el
/// `Hal`/`Renderer` de la primaria; camino simple (sin overlay ni
/// composite gpu de menús), pero soporta `gpu_paint` por si el contenido
/// lo usa.
pub(super) fn render_secondary(&mut self, idx: usize) {
let key = self.secondaries[idx].key;
let Some(prim) = self.state.as_mut() else {
return;
};
// Título dinámico de la secundaria.
if let Some(t) = A::secondary_title(prim.model.as_ref().expect("model"), key) {
let sec = &mut self.secondaries[idx];
if sec.last_title.as_deref() != Some(t.as_str()) {
sec.window.set_title(&t);
sec.last_title = Some(t);
}
}
let view = A::secondary_view(prim.model.as_ref().expect("model"), key)
.unwrap_or_else(|| View::new(Default::default()));
let hal = &prim.hal;
let renderer = &mut prim.renderer;
let sec = &mut self.secondaries[idx];
let frame = match sec.surface.acquire() {
Ok(f) => f,
Err(_) => {
let (w, h) = sec.surface.size();
sec.surface.resize(w, h);
sec.window.request_redraw();
return;
}
};
let (w, h) = frame.size();
sec.layout.clear();
let mounted: Mounted<A::Msg> = mount(&mut sec.layout, view);
let computed = {
let ts = &mut sec.typesetter;
let tmap = &mounted.text_measures;
sec.layout
.compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| {
match tmap.get(&nid) {
Some(tm) => measure_text_node(ts, tm, known, avail),
None => llimphi_layout::taffy::Size::ZERO,
}
})
.expect("layout secundario")
};
let hover_idx = hit_test_hover(&mounted, &computed, sec.cursor.x as f32, sec.cursor.y as f32);
let drop_hover_idx = sec
.drag
.as_ref()
.and_then(|d| d.payload)
.and_then(|_| hit_test_drop(&mounted, &computed, sec.cursor.x as f32, sec.cursor.y as f32));
sec.scene.reset();
paint(
&mut sec.scene,
&mounted,
&computed,
&mut sec.typesetter,
hover_idx,
drop_hover_idx,
);
if let Err(e) = renderer.render(hal, &sec.scene, &frame, palette::css::BLACK) {
eprintln!("render secundario error: {e}");
}
// gpu_paint del contenido de la secundaria (si lo hubiera).
let mut enc = hal
.device
.create_command_encoder(&llimphi_hal::wgpu::CommandEncoderDescriptor {
label: Some("llimphi-ui-sec-gpu"),
});
let viewport = frame.size();
let any = paint_gpu(
&mounted,
&computed,
&hal.device,
&hal.queue,
&mut enc,
frame.view(),
viewport,
);
if any {
hal.queue.submit(std::iter::once(enc.finish()));
}
sec.surface.present(frame, hal);
let _ = (hover_idx, drop_hover_idx); // se usaron al pintar; no se cachean
sec.last_render = Some(SecRenderCache { mounted, computed });
}
/// Atiende un evento de la ventana secundaria `idx`. Subconjunto de lo que
/// hace la primaria (sin overlay/foco/IME): render, resize, cierre, hover,
/// click, drag, teclado y rueda — suficiente para un panel de config.
pub(super) fn handle_secondary_event(
&mut self,
idx: usize,
event: llimphi_hal::winit::event::WindowEvent,
) {
use llimphi_hal::winit::event::WindowEvent;
match event {
WindowEvent::CloseRequested => {
let key = self.secondaries[idx].key;
let msg = self
.state
.as_ref()
.and_then(|p| A::on_secondary_close(p.model.as_ref().expect("model"), key));
self.secondaries.remove(idx);
if let Some(msg) = msg {
self.dispatch_model(msg);
}
}
WindowEvent::Resized(size) => {
self.secondaries[idx].surface.resize(size.width, size.height);
self.render_secondary(idx);
}
WindowEvent::ScaleFactorChanged { .. } => {
self.render_secondary(idx);
}
WindowEvent::RedrawRequested => {
self.render_secondary(idx);
}
WindowEvent::ModifiersChanged(mods) => {
self.secondaries[idx].modifiers = mods.state().into();
}
WindowEvent::CursorMoved { position, .. } => {
let mut drag_msg: Option<A::Msg> = None;
let mut move_call: Option<(ClickAtFn<A::Msg>, f32, f32, f32, f32)> = None;
let mut redraw = false;
{
let sec = &mut self.secondaries[idx];
sec.cursor = position;
if let Some(drag) = sec.drag.as_mut() {
let dx = (position.x - drag.last_cursor.x) as f32;
let dy = (position.y - drag.last_cursor.y) as f32;
drag.last_cursor = position;
if dx != 0.0 || dy != 0.0 {
drag_msg = match &drag.handler {
DragHandlerKind::Delta(h) => h(DragPhase::Move, dx, dy),
DragHandlerKind::DeltaAt(h, lx0, ly0) => {
h(DragPhase::Move, dx, dy, *lx0, *ly0)
}
DragHandlerKind::Velocity(h) => {
let now = std::time::Instant::now();
drag.samples.push_back((now, dx as f64, dy as f64));
while drag.samples.len() > VELOCITY_MAX_SAMPLES {
drag.samples.pop_front();
}
h(DragPhase::Move, dx, dy, 0.0, 0.0)
}
};
}
redraw = true;
} else {
let new_hover = sec.last_render.as_ref().and_then(|c| {
hit_test_hover(&c.mounted, &c.computed, position.x as f32, position.y as f32)
});
if new_hover != sec.hovered {
sec.hovered = new_hover;
redraw = true;
}
// Movimiento posicional (on_pointer_move_at) en cada move.
move_call = sec.last_render.as_ref().and_then(|c| {
let i = hit_test_pointer_move(
&c.mounted,
&c.computed,
position.x as f32,
position.y as f32,
)?;
let node = &c.mounted.nodes[i];
let h = node.on_pointer_move_at.clone()?;
let r = c.computed.get(node.id)?;
Some((h, position.x as f32 - r.x, position.y as f32 - r.y, r.w, r.h))
});
}
}
let move_msg = move_call.and_then(|(h, lx, ly, w, hh)| h(lx, ly, w, hh));
if let Some(msg) = drag_msg.or(move_msg) {
self.dispatch_and_render_secondary(idx, msg);
} else if redraw {
self.render_secondary(idx);
}
}
WindowEvent::MouseInput {
state: ElementState::Pressed,
button: MouseButton::Left,
..
} => {
type SecHit<M> = (
Option<DragFn<M>>,
Option<DragAtFn<M>>,
Option<DragVelocityFn<M>>,
Option<u64>,
Option<M>,
Option<ClickAtFn<M>>,
Option<(f32, f32, f32, f32)>,
);
let cursor = self.secondaries[idx].cursor;
let hit: Option<SecHit<A::Msg>> = {
let sec = &self.secondaries[idx];
sec.last_render.as_ref().and_then(|c| {
hit_test_click(&c.mounted, &c.computed, cursor.x as f32, cursor.y as f32).map(
|i| {
let node = &c.mounted.nodes[i];
let rect = c.computed.get(node.id).map(|r| (r.x, r.y, r.w, r.h));
(
node.drag.clone(),
node.drag_at.clone(),
node.drag_velocity.clone(),
node.drag_payload,
node.on_click.clone(),
node.on_click_at.clone(),
rect,
)
},
)
})
};
// Misma prioridad que la primaria: drag_velocity > drag_at +
// on_click_at > drag simple > on_click_at > on_click.
match hit {
Some((_, _, Some(handler_v), payload, _, _, _)) => {
self.secondaries[idx].drag = Some(DragState {
handler: DragHandlerKind::Velocity(handler_v),
last_cursor: cursor,
payload,
samples: std::collections::VecDeque::with_capacity(VELOCITY_MAX_SAMPLES),
});
self.render_secondary(idx);
}
Some((_, Some(handler_at), _, payload, _, click_at, Some((ox, oy, rw, rh)))) => {
let lx0 = cursor.x as f32 - ox;
let ly0 = cursor.y as f32 - oy;
if let Some(h) = click_at {
if let Some(msg) = h(lx0, ly0, rw, rh) {
self.dispatch_model(msg);
}
}
self.secondaries[idx].drag = Some(DragState {
handler: DragHandlerKind::DeltaAt(handler_at, lx0, ly0),
last_cursor: cursor,
payload,
samples: std::collections::VecDeque::new(),
});
self.render_secondary(idx);
}
Some((Some(handler), _, _, payload, _, _, _)) => {
self.secondaries[idx].drag = Some(DragState {
handler: DragHandlerKind::Delta(handler),
last_cursor: cursor,
payload,
samples: std::collections::VecDeque::new(),
});
self.render_secondary(idx);
}
Some((_, _, _, _, _, Some(handler), Some((ox, oy, rw, rh)))) => {
let lx = cursor.x as f32 - ox;
let ly = cursor.y as f32 - oy;
if let Some(msg) = handler(lx, ly, rw, rh) {
self.dispatch_and_render_secondary(idx, msg);
}
}
Some((_, _, _, _, Some(msg), _, _)) => {
self.dispatch_and_render_secondary(idx, msg);
}
_ => {}
}
}
WindowEvent::MouseInput {
state: ElementState::Released,
button: MouseButton::Left,
..
} => {
let cursor = self.secondaries[idx].cursor;
let drag = self.secondaries[idx].drag.take();
if let Some(drag) = drag {
// Drop primero (si hay payload + target), luego End.
if let Some(payload) = drag.payload {
let drop_h = self.secondaries[idx].last_render.as_ref().and_then(|c| {
hit_test_drop(&c.mounted, &c.computed, cursor.x as f32, cursor.y as f32)
.and_then(|i| c.mounted.nodes[i].on_drop.clone())
});
if let Some(h) = drop_h {
if let Some(msg) = h(payload) {
self.dispatch_model(msg);
}
}
}
let end_msg = match &drag.handler {
DragHandlerKind::Delta(h) => h(DragPhase::End, 0.0, 0.0),
DragHandlerKind::DeltaAt(h, lx0, ly0) => h(DragPhase::End, 0.0, 0.0, *lx0, *ly0),
DragHandlerKind::Velocity(h) => {
let (vx, vy) =
compute_drag_velocity(&drag.samples, std::time::Instant::now());
h(DragPhase::End, 0.0, 0.0, vx, vy)
}
};
if let Some(msg) = end_msg {
self.dispatch_model(msg);
}
self.render_secondary(idx);
}
}
WindowEvent::MouseWheel { delta, .. } => {
let wd = match delta {
MouseScrollDelta::LineDelta(x, y) => WheelDelta { x, y: -y },
MouseScrollDelta::PixelDelta(p) => WheelDelta {
x: (p.x as f32) / 20.0,
y: -(p.y as f32) / 20.0,
},
};
let cursor = self.secondaries[idx].cursor;
let chain: Vec<ScrollFn<A::Msg>> = {
let sec = &self.secondaries[idx];
sec.last_render
.as_ref()
.map(|c| {
hit_test_scroll_chain(
&c.mounted,
&c.computed,
cursor.x as f32,
cursor.y as f32,
)
.into_iter()
.filter_map(|i| c.mounted.nodes[i].on_scroll.clone())
.collect()
})
.unwrap_or_default()
};
let mut msg: Option<A::Msg> = None;
for h in &chain {
if let Some(m) = h(wd.x, wd.y) {
msg = Some(m);
break;
}
}
if let Some(msg) = msg {
self.dispatch_and_render_secondary(idx, msg);
}
}
WindowEvent::KeyboardInput { event, .. } => {
let ev = KeyEvent {
key: event.logical_key.clone(),
state: match event.state {
ElementState::Pressed => KeyState::Pressed,
ElementState::Released => KeyState::Released,
},
text: event.text.as_ref().map(|t| t.to_string()),
modifiers: self.secondaries[idx].modifiers,
repeat: event.repeat,
};
let msg = self
.state
.as_ref()
.and_then(|p| A::on_key(p.model.as_ref().expect("model"), &ev));
if let Some(msg) = msg {
self.dispatch_and_render_secondary(idx, msg);
}
}
_ => {}
}
}
}
+343 -4
View File
@@ -12,6 +12,8 @@
use std::sync::Arc;
pub mod a11y;
use llimphi_hal::winit::application::ApplicationHandler;
use llimphi_hal::winit::dpi::{LogicalSize, PhysicalPosition};
use llimphi_hal::winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent};
@@ -207,7 +209,7 @@ pub trait App: 'static {
/// Identificador de aplicación. En Wayland se mapea al `app_id` del
/// xdg-toplevel (lo que el compositor usa para reconocer la ventana,
/// p. ej. `carmen.greeter`). `None` deja que el sistema asigne uno.
/// p. ej. `mirada.greeter`). `None` deja que el sistema asigne uno.
fn app_id() -> Option<&'static str> {
None
}
@@ -237,6 +239,19 @@ pub enum UserEvent<Msg> {
},
/// Pide cerrar la ventana secundaria con esa `key`. No afecta a la primaria.
CloseWindow { key: u64 },
/// Evento del adapter AccessKit: el lector de pantalla solicitó el árbol
/// inicial, pidió ejecutar una acción (focus, click, etc.) o se desactivó.
/// El adapter usa el `EventLoopProxy` para enviarlos al hilo del runtime.
A11y(accesskit_winit::Event),
}
/// Permite que `accesskit_winit::Adapter::with_event_loop_proxy` mande sus
/// eventos sobre nuestro `EventLoopProxy<UserEvent<Msg>>` sin que el caller
/// los rutee a mano.
impl<Msg> From<accesskit_winit::Event> for UserEvent<Msg> {
fn from(e: accesskit_winit::Event) -> Self {
UserEvent::A11y(e)
}
}
/// Asa al runtime de Llimphi. Clonable y enviable entre hilos: la usás para
@@ -256,6 +271,13 @@ enum HandleInner<Msg: Send + 'static> {
/// llamar funciones que toman `&Handle<Msg>` sin levantar un event
/// loop real (que en CI sin display tiraría).
Test,
/// Handle **lifteado**: reenvía cada `Msg` (de un sub-app hospedado) al
/// handle del host aplicándole una función de elevación `Sub -> Host`. Lo
/// crea [`Handle::lift`]; permite que el `update` de un app embebido use
/// `dispatch`/`spawn`/`spawn_periodic` con su propio `Msg` y que el
/// resultado llegue al loop del host. No maneja ventanas (open/close/quit
/// son no-op): esas son del host, no del hospedado.
Lifted(Arc<dyn Fn(Msg) + Send + Sync>),
}
impl<Msg: Send + 'static> Clone for Handle<Msg> {
@@ -264,6 +286,7 @@ impl<Msg: Send + 'static> Clone for Handle<Msg> {
inner: match &self.inner {
HandleInner::Real(p) => HandleInner::Real(p.clone()),
HandleInner::Test => HandleInner::Test,
HandleInner::Lifted(f) => HandleInner::Lifted(f.clone()),
},
}
}
@@ -288,6 +311,26 @@ impl<Msg: Send + 'static> Handle<Msg> {
let _ = p.send_event(UserEvent::Quit);
}
HandleInner::Test => {}
// Un app hospedado no cierra el loop del host.
HandleInner::Lifted(_) => {}
}
}
/// Deriva un handle para un **sub-app hospedado**: el `update`/efectos del
/// sub-app usan su propio `Sub` msg, y `lift` los eleva al `Msg` del host
/// antes de despacharlos a este loop. Es la pieza que permite embeber un
/// App entero en otro (junto con [`crate::View::map`] para su `view`) sin
/// reescribirlo a patrón módulo. El sub-handle es `Clone + Send` como
/// cualquier handle. `open_window`/`close_window`/`quit` quedan no-op en él
/// (esas son del host).
pub fn lift<Sub, F>(&self, lift: F) -> Handle<Sub>
where
Sub: Send + 'static,
F: Fn(Sub) -> Msg + Send + Sync + 'static,
{
let parent = self.clone();
Handle {
inner: HandleInner::Lifted(Arc::new(move |sub: Sub| parent.dispatch(lift(sub)))),
}
}
@@ -324,6 +367,7 @@ impl<Msg: Send + 'static> Handle<Msg> {
let _ = p.send_event(UserEvent::Msg(msg));
}
HandleInner::Test => {}
HandleInner::Lifted(f) => f(msg),
}
}
@@ -349,6 +393,14 @@ impl<Msg: Send + 'static> Handle<Msg> {
let _ = f();
});
}
HandleInner::Lifted(lift) => {
// Tarea one-shot del sub-app: corre en su hilo y el resultado
// se eleva al host vía la closure de lift.
let lift = lift.clone();
std::thread::spawn(move || {
lift(f());
});
}
}
}
@@ -385,6 +437,17 @@ impl<Msg: Send + 'static> Handle<Msg> {
// periodic behaviour deben usar el callback directo.
let _ = f;
}
HandleInner::Lifted(lift) => {
// Mismo loop que `Real` pero elevando al host. Si el loop del
// host se cerró, la closure de lift termina en un dispatch
// no-op (spinea hasta el exit, costo despreciable — igual que
// `Real`); aceptable para un ticker de animación/feed.
let lift = lift.clone();
std::thread::spawn(move || loop {
std::thread::sleep(period);
lift(f());
});
}
}
}
}
@@ -508,6 +571,17 @@ struct RuntimeState<A: App> {
/// Sólo entra en juego cuando el árbol principal tiene painters gpu y hay
/// un overlay activo; resuelve el z-order (menús por encima del video).
overlay_compositor: llimphi_hal::OverlayCompositor,
/// Backdrop blur post-pasada: para cada nodo con `.backdrop_blur(sigma)`,
/// el runtime aplica un Gauss separable (H+V) sobre la intermediate
/// restringido al rect del nodo, **después** de la rasterización vello y
/// **antes** de los `gpu_painter`. El compositor mantiene su scratch
/// interno; coste cero cuando no hay nodos blur.
blur_compositor: llimphi_hal::BlurCompositor,
/// Post-pasada de **matriz de color** (`filter: brightness/grayscale/…`),
/// restringida al rect del nodo, en el mismo punto que `blur_compositor`.
/// Mantiene su scratch interno; coste cero sin filtros de color. Fase
/// 7.1233.
color_filter_compositor: llimphi_hal::ColorFilterCompositor,
model: Option<A::Model>,
cursor: PhysicalPosition<f64>,
modifiers: Modifiers,
@@ -541,6 +615,84 @@ struct RuntimeState<A: App> {
/// Último título dinámico aplicado a la ventana (ver [`App::window_title`]).
/// Evita llamar `set_title` en cada frame cuando no cambió.
last_title: Option<String>,
/// Registro de animaciones implícitas (`View::animated`), vivo entre
/// frames. En cada redraw reconcilia el árbol y, si alguna sigue en curso,
/// el runtime pide otro frame (ticker autodetenido). Ver
/// [`llimphi_compositor::AnimRegistry`].
anim_registry: llimphi_compositor::AnimRegistry,
/// Registro de animaciones implícitas de **tamaño**
/// (`View::animated_size`, Flutter `AnimatedSize`), vivo entre frames.
/// A diferencia de [`Self::anim_registry`] que reconcilia props de
/// paint DESPUÉS del layout, este reconcilia `style.size`
/// **antes** del mount/compute, así siblings/hijos reflowean suave.
/// Ver [`llimphi_compositor::SizeAnimRegistry`].
size_anim_registry: llimphi_compositor::SizeAnimRegistry,
/// Registro de **heroes / shared-element transitions** (`View::hero`),
/// vivo entre frames. Detecta cambio de rect de una misma `key` entre
/// frames y escribe `transform` para "volar" del rect anterior al actual.
/// Ver [`llimphi_compositor::HeroRegistry`].
hero_registry: llimphi_compositor::HeroRegistry,
/// Adapter [AccessKit](https://accesskit.dev) — empuja un árbol de
/// accesibilidad al SO en cada paint para alimentar lectores de pantalla.
/// Sólo se inicializa si el SO tiene una tecnología asistiva activa; el
/// `update_if_active` evita construir el árbol cuando nadie escucha.
a11y_adapter: accesskit_winit::Adapter,
/// Identidad estable del árbol de accesibilidad entre `TreeUpdate`s. Se
/// genera una vez al crear el runtime y se reutiliza en cada update — los
/// lectores la usan para distinguir nuestra ventana de otras del SO.
a11y_tree_id: accesskit::TreeId,
/// Registro de **ripples/InkWell** (`View::ripple`), vivo entre frames. El
/// press dispara una salpicadura; cada redraw la pinta sobre el contenido y,
/// mientras alguna siga viva, pide otro frame (ticker autodetenido). Ver
/// [`llimphi_compositor::RippleRegistry`].
ripple_registry: llimphi_compositor::RippleRegistry,
/// Último tap (press izquierdo) sobre un nodo con `on_double_tap`: instante
/// + posición. El próximo press que caiga cerca y a tiempo dispara el
/// doble-tap. `None` cuando no hay un primer tap pendiente.
last_tap: Option<(std::time::Instant, PhysicalPosition<f64>)>,
/// Long-press armado (ver [`PendingLongPress`]). El runtime lo vence por
/// tiempo en `about_to_wait` y lo cancela en movimiento/release.
pending_long_press: Option<PendingLongPress<A::Msg>>,
/// **Retención de frame entero**. Tras un paint exitoso, guardamos las
/// dimensiones del viewport y los flags de animación del frame. Si en el
/// próximo `RedrawRequested` ningún sitio invalidó `last_render` (la
/// invariante existente del runtime), el modelo + view + layout son
/// idénticos al frame anterior: no hace falta rehacer mount/layout/paint,
/// alcanza con re-presentar `state.scene` tal cual quedó. Mata redraws
/// espurios (expose del compositor, refocus, ticker en el último frame de
/// una anim ya asentada). Si el frame retenido estaba animando o ripplando,
/// el ticker NECESITA avanzarlo → no hay retención (cache miss). Tampoco
/// hay retención con overlay o drag activos (camino conservador). Ver el
/// hit-check en `RedrawRequested`.
retained: Option<RetainedScene>,
/// Selección de texto activa fuera del editor (drag para resaltar, Ctrl/Cmd+C
/// para copiar). `None` = nada seleccionado. Ver [`TextSelection`].
selection: Option<TextSelection>,
}
/// Metadata del frame retenido — qué pintó la `state.scene` para validar que
/// re-presentarla sin re-pintar es seguro.
#[derive(Clone, Copy)]
struct RetainedScene {
w: u32,
h: u32,
animating: bool,
rippling: bool,
has_overlay: bool,
}
/// Selección de texto activa fuera del editor (ver [`crate::View::selectable`]).
/// Anclada a la `key` estable del nodo (no a su `NodeId`, que cambia cada
/// frame); el runtime reconstruye el `parley::Layout` del nodo bajo esa key
/// para extender la selección al arrastrar y para pintar el resaltado.
#[derive(Clone, Copy)]
struct TextSelection {
/// Key estable del nodo seleccionable (`text_select_key`).
key: u64,
/// Rango seleccionado, en coordenadas de bytes del `parley::Layout`.
sel: llimphi_text::parley::Selection,
/// `true` mientras el botón izquierdo sigue apretado (arrastrando).
dragging: bool,
}
struct RenderCache<Msg> {
@@ -565,12 +717,71 @@ struct OverlayCache<Msg> {
hover_idx: Option<usize>,
}
/// Dos sabores de handler de drag activo: el simple `(phase, dx, dy)`
/// o la variante que conserva la posición local del press original
/// `(phase, dx, dy, lx0, ly0)`. El runtime elige uno al iniciar el drag.
/// Tres sabores de handler de drag activo: el simple `(phase, dx, dy)`;
/// la variante que conserva la posición local del press original
/// `(phase, dx, dy, lx0, ly0)`; o el handler **con velocidad** que recibe
/// también `(vx, vy)` al `DragPhase::End` (medida sobre los últimos
/// [`VELOCITY_WINDOW`] de movimiento). El runtime elige uno al iniciar el
/// drag — un nodo es uno u otro.
enum DragHandlerKind<Msg> {
Delta(DragFn<Msg>),
DeltaAt(DragAtFn<Msg>, f32, f32),
Velocity(DragVelocityFn<Msg>),
}
/// Un handler de gesto "tipo click" (doble-tap / long-press) ya **resuelto**
/// contra el nodo: o un `Msg` directo, o un handler posicional con la posición
/// local `(lx, ly, w, h)` ya calculada. Se captura en el press para poder
/// dispararlo más tarde (long-press, que vence por tiempo) sin volver a tocar
/// el árbol.
enum GestureResolved<Msg> {
Direct(Msg),
At(ClickAtFn<Msg>, f32, f32, f32, f32),
}
impl<Msg: Clone> GestureResolved<Msg> {
/// Materializa el `Msg` (clona el directo o invoca el handler posicional).
fn invoke(&self) -> Option<Msg> {
match self {
GestureResolved::Direct(m) => Some(m.clone()),
GestureResolved::At(h, lx, ly, w, ht) => h(*lx, *ly, *w, *ht),
}
}
}
/// Long-press **armado**: el press cayó sobre un nodo con `on_long_press`. El
/// runtime lo dispara cuando pasa `deadline` (en `about_to_wait`), salvo que
/// antes el cursor se aleje de `origin` (pasó a drag) o se suelte el botón —
/// en ambos casos se cancela. Es la parte de "arena" del gesto: el árbitro es
/// el tiempo + el movimiento.
struct PendingLongPress<Msg> {
deadline: std::time::Instant,
origin: PhysicalPosition<f64>,
handler: GestureResolved<Msg>,
}
/// Umbral de duración para que un press se convierta en long-press.
const LONG_PRESS_DELAY: std::time::Duration = std::time::Duration::from_millis(500);
/// Si el cursor se aleja más que esto (px físicos) del origen del press, deja
/// de ser long-press (pasó a drag/scroll) y se cancela.
const LONG_PRESS_MOVE_CANCEL: f64 = 8.0;
/// Ventana temporal máxima entre los dos taps de un doble-tap.
const DOUBLE_TAP_WINDOW: std::time::Duration = std::time::Duration::from_millis(400);
/// Distancia máxima (px físicos) entre los dos taps de un doble-tap.
const DOUBLE_TAP_DIST: f64 = 16.0;
/// ¿El press actual (`now`, `pos`) completa un doble-tap con el tap previo
/// `last`? Verdadero si hubo un tap previo dentro de [`DOUBLE_TAP_WINDOW`] y a
/// menos de [`DOUBLE_TAP_DIST`]. Función pura (testeable sin event loop).
fn double_tap_qualifies(
last: Option<(std::time::Instant, PhysicalPosition<f64>)>,
now: std::time::Instant,
pos: PhysicalPosition<f64>,
) -> bool {
last.is_some_and(|(t, p)| {
now.duration_since(t) <= DOUBLE_TAP_WINDOW
&& ((p.x - pos.x).powi(2) + (p.y - pos.y).powi(2)).sqrt() <= DOUBLE_TAP_DIST
})
}
struct DragState<Msg> {
@@ -583,6 +794,48 @@ struct DragState<Msg> {
/// origen no declaró ninguno (drag de resize/scroll/etc.). Los drop
/// targets sólo reaccionan cuando hay payload.
payload: Option<u64>,
/// Buffer móvil de (timestamp, dx, dy) por cada `CursorMoved` durante
/// el drag, recortado a [`VELOCITY_MAX_SAMPLES`]. Sólo se usa cuando el
/// handler es [`DragHandlerKind::Velocity`] — en los otros sabores
/// queda vacío. Al `DragPhase::End` el runtime computa la velocidad
/// sobre la ventana [`VELOCITY_WINDOW`].
samples: std::collections::VecDeque<(std::time::Instant, f64, f64)>,
}
/// Ventana temporal sobre la que se mide la velocidad de un drag al
/// soltarlo. Movimientos más viejos no cuentan — sólo importa el último
/// flick. ~100 ms es el valor que usa Android para fling.
const VELOCITY_WINDOW: std::time::Duration = std::time::Duration::from_millis(100);
/// Tope superior de muestras retenidas en el buffer móvil de velocidad —
/// con eventos típicos de 60120 Hz, ocho muestras cubren la ventana
/// holgadamente. Más allá es ruido y costo.
const VELOCITY_MAX_SAMPLES: usize = 8;
/// Velocidad (px/s) calculada sobre los últimos [`VELOCITY_WINDOW`] de
/// movimiento. Toma sólo las muestras dentro de la ventana, suma los
/// deltas y divide por el tiempo transcurrido desde la primera muestra
/// retenida hasta `now`. Función pura para testear sin event loop.
fn compute_drag_velocity(
samples: &std::collections::VecDeque<(std::time::Instant, f64, f64)>,
now: std::time::Instant,
) -> (f32, f32) {
if samples.is_empty() {
return (0.0, 0.0);
}
let cutoff = now.checked_sub(VELOCITY_WINDOW).unwrap_or(now);
let recent: Vec<&(std::time::Instant, f64, f64)> =
samples.iter().filter(|(t, _, _)| *t >= cutoff).collect();
if recent.is_empty() {
return (0.0, 0.0);
}
let t0 = recent[0].0;
let dt = now.duration_since(t0).as_secs_f32();
if dt < 0.001 {
return (0.0, 0.0);
}
let sum_dx: f64 = recent.iter().map(|(_, dx, _)| *dx).sum();
let sum_dy: f64 = recent.iter().map(|(_, _, dy)| *dy).sum();
((sum_dx as f32) / dt, (sum_dy as f32) / dt)
}
/// Punto de entrada: corre el bucle Elm hasta que el usuario cierre la
@@ -602,3 +855,89 @@ pub fn run<A: App>() {
};
event_loop.run_app(&mut runtime).expect("run app");
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{Duration, Instant};
#[test]
fn lift_aplica_la_funcion_de_elevacion() {
use std::sync::{Arc, Mutex};
// `lift` aplica la función Sub->Host síncronamente en `dispatch` (el
// dispatch al padre Test es no-op, pero la elevación corre): así
// observamos que el msg del sub-app se transforma para el host.
let seen = Arc::new(Mutex::new(Vec::<i32>::new()));
let parent: Handle<i32> = Handle::for_test();
let sub: Handle<String> = {
let seen = seen.clone();
parent.lift(move |s: String| {
let n = s.len() as i32;
seen.lock().unwrap().push(n);
n
})
};
sub.dispatch("hola".to_string());
let _ = sub.clone(); // es Clone como cualquier handle
assert_eq!(*seen.lock().unwrap(), vec![4]);
}
#[test]
fn velocidad_de_drag_promedia_dentro_de_la_ventana() {
use std::collections::VecDeque;
let now = Instant::now();
// Cuatro muestras dentro de la ventana (últimos 80 ms): 4 px en x cada
// 20 ms ⇒ 16 px en 80 ms ⇒ 200 px/s.
let mut samples: VecDeque<(Instant, f64, f64)> = VecDeque::new();
samples.push_back((now - Duration::from_millis(80), 4.0, 0.0));
samples.push_back((now - Duration::from_millis(60), 4.0, 0.0));
samples.push_back((now - Duration::from_millis(40), 4.0, 0.0));
samples.push_back((now - Duration::from_millis(20), 4.0, 0.0));
let (vx, vy) = compute_drag_velocity(&samples, now);
assert!((vx - 200.0).abs() < 1.0, "vx={vx}");
assert!(vy.abs() < 1e-3);
// Buffer vacío → (0,0).
let empty: VecDeque<(Instant, f64, f64)> = VecDeque::new();
assert_eq!(compute_drag_velocity(&empty, now), (0.0, 0.0));
// Muestras todas más viejas que VELOCITY_WINDOW → (0,0) (no hay
// movimiento reciente para fling).
let mut old: VecDeque<(Instant, f64, f64)> = VecDeque::new();
old.push_back((now - Duration::from_millis(500), 10.0, 10.0));
assert_eq!(compute_drag_velocity(&old, now), (0.0, 0.0));
// Eje y positivo (scroll vertical típico): 5 px cada 25 ms ⇒ 200 px/s.
let mut vy_samples: VecDeque<(Instant, f64, f64)> = VecDeque::new();
vy_samples.push_back((now - Duration::from_millis(75), 0.0, 5.0));
vy_samples.push_back((now - Duration::from_millis(50), 0.0, 5.0));
vy_samples.push_back((now - Duration::from_millis(25), 0.0, 5.0));
let (_, vy) = compute_drag_velocity(&vy_samples, now);
assert!((vy - 200.0).abs() < 1.0, "vy={vy}");
}
#[test]
fn double_tap_ventana_y_distancia() {
let t0 = Instant::now();
let p = PhysicalPosition::new(100.0, 100.0);
// Sin tap previo → nunca califica.
assert!(!double_tap_qualifies(None, t0, p));
// Segundo tap a tiempo (100 ms < 400) y cerca (3px < 16) → califica.
let near = PhysicalPosition::new(102.0, 102.0);
assert!(double_tap_qualifies(
Some((t0, p)),
t0 + Duration::from_millis(100),
near
));
// A tiempo pero lejos (>16px) → no.
let far = PhysicalPosition::new(140.0, 100.0);
assert!(!double_tap_qualifies(
Some((t0, p)),
t0 + Duration::from_millis(100),
far
));
// Cerca pero tarde (>400 ms) → no.
assert!(!double_tap_qualifies(
Some((t0, p)),
t0 + Duration::from_millis(600),
near
));
}
}