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:
@@ -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"
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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
@@ -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 => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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 60–120 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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user