tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ 993b7625b84f5f10b70866e0497e80de22aaa880
llimphi-ui/src/a11y.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel
1//! Traducción del árbol Llimphi a un árbol [AccessKit](https://accesskit.dev)
2//! para alimentar lectores de pantalla y otras tecnologías de asistencia.
3//!
4//! Cada frame el runtime llama a [`build_tree`] con el árbol montado +
5//! `ComputedLayout` + el id de foco actual. La función produce un
6//! `accesskit::TreeUpdate` que el adapter (`accesskit_winit::Adapter`) empuja
7//! al sistema operativo.
8//!
9//! ## Mapeo de identidades
10//!
11//! Cada `MountedNode` recibe un `NodeId(idx + ROOT_OFFSET)` derivado de su
12//! índice en `Mounted::nodes` — estable dentro de un frame, no necesariamente
13//! entre frames (si la app re-renderiza un árbol distinto, los ids cambian).
14//! `ROOT_NODE_ID` queda reservado para el nodo raíz sintético que envuelve
15//! todo el árbol. Los nodos sin semántica declarada igual aparecen en el árbol
16//! si contienen texto, son `focusable` o tienen `on_click` — los lectores los
17//! anuncian aunque el caller no haya marcado un rol explícito.
18//!
19//! ## Acciones soportadas (v1)
20//!
21//! - `Action::Focus`: mueve el foco de Llimphi a ese nodo (vía
22//! [`crate::App::on_focus`]).
23//! - `Action::Click` / `Default`: ejecuta el `on_click` del nodo si existe;
24//! los handlers `*_at` se ignoran en v1 (no tienen una posición sintética
25//! coherente — la documentamos como limitación).
26
27use accesskit::{Action, Node, NodeId, Rect as AkRect, Role as AkRole, Tree, TreeId, TreeUpdate};
28use llimphi_compositor::{Mounted, Role as LRole, SemanticsSpec};
29use llimphi_layout::ComputedLayout;
30
31/// NodeId reservado para la raíz sintética del árbol. El nodo App es siempre
32/// el padre lógico de todos los `MountedNode` que producimos.
33pub const ROOT_NODE_ID: NodeId = NodeId(1);
34
35/// Offset desde el cual numeramos los `MountedNode`. Deja el rango [0, OFFSET)
36/// para ids reservados (root y futuros nodos sintéticos como overlays).
37const MOUNTED_OFFSET: u64 = 1000;
38
39/// `NodeId` AccessKit asignado al `MountedNode` con índice `idx`. La función
40/// es inversa de [`mounted_idx_for`].
41pub fn node_id_for(idx: usize) -> NodeId {
42 NodeId(MOUNTED_OFFSET + idx as u64)
43}
44
45/// Recupera el índice del `MountedNode` que corresponde a un `NodeId`
46/// AccessKit, o `None` si el id está fuera del rango de nodos montados.
47pub fn mounted_idx_for(id: NodeId) -> Option<usize> {
48 let v = id.0;
49 if v >= MOUNTED_OFFSET {
50 Some((v - MOUNTED_OFFSET) as usize)
51 } else {
52 None
53 }
54}
55
56/// Construye el árbol AccessKit completo para el frame actual.
57///
58/// `focused_idx` es el índice del `MountedNode` enfocado (si lo hay) —
59/// resolvelo desde `state.focused: Option<u64>` mapeando contra el campo
60/// `focusable` de cada MountedNode.
61pub fn build_tree<Msg>(
62 mounted: &Mounted<Msg>,
63 computed: &ComputedLayout,
64 focused_idx: Option<usize>,
65 app_name: &str,
66 tree_id: TreeId,
67) -> TreeUpdate {
68 let mut nodes: Vec<(NodeId, Node)> = Vec::with_capacity(mounted.nodes.len() + 1);
69
70 // 1) Raíz sintética: lista los hijos top-level (los nodos cuya posición en
71 // el array es 0 o queda fuera de cualquier subárbol previo). En la práctica
72 // el `MountedNode` con índice 0 es la raíz del View — la usamos directo.
73 let mut root = Node::new(AkRole::Window);
74 root.set_label(app_name.to_string());
75 if !mounted.nodes.is_empty() {
76 root.set_children(vec![node_id_for(0)]);
77 }
78 nodes.push((ROOT_NODE_ID, root));
79
80 // 2) Un Node AccessKit por cada MountedNode. Hijos = los hijos directos en
81 // el árbol pre-orden (subtree_end nos da el rango).
82 for (idx, mn) in mounted.nodes.iter().enumerate() {
83 let mut node = Node::new(map_role(&mn.semantics, mn));
84
85 // Bounds del nodo (rect absoluto del layout). Sin esto el lector no
86 // sabe dónde está visualmente y la navegación por reading order
87 // degrada a "como vinieron".
88 if let Some(r) = computed.get(mn.id) {
89 node.set_bounds(AkRect {
90 x0: r.x as f64,
91 y0: r.y as f64,
92 x1: (r.x + r.w) as f64,
93 y1: (r.y + r.h) as f64,
94 });
95 }
96
97 // Label / value / description. Si la app declaró semantics, mandamos
98 // esos. Si no, intentamos derivar un label del texto visible — los
99 // lectores leen igualmente texto sin rol, pero un label explícito es
100 // más claro.
101 if let Some(spec) = &mn.semantics {
102 apply_semantics(&mut node, spec);
103 // Si declaró rol pero no label y hay texto plano en el nodo,
104 // caemos al texto: cubre widgets como `app-header` que setean
105 // `.role(Heading)` sobre un nodo con `text_aligned("Título", …)`
106 // sin duplicar el string en `.aria_label(...)`.
107 if spec.label.is_none() {
108 if let Some(t) = &mn.text {
109 node.set_label(t.content.clone());
110 }
111 }
112 } else if let Some(t) = &mn.text {
113 node.set_label(t.content.clone());
114 }
115
116 // Acciones: declaramos las que el adapter va a recibir y ejecutar.
117 // `Focus` para cualquier nodo enfocable; `Click` para cualquier nodo
118 // con `on_click`. El handler del runtime las despacha en `act` (ver
119 // eventloop.rs).
120 if mn.focusable.is_some() {
121 node.add_action(Action::Focus);
122 }
123 if mn.on_click.is_some() || mn.on_click_at.is_some() {
124 node.add_action(Action::Click);
125 }
126
127 // Hijos: rango [idx+1, subtree_end) — pero acá necesitamos sólo los
128 // hijos DIRECTOS, no descendientes. Los hijos directos son los nodos
129 // cuyo padre es este: en el orden pre-orden con `subtree_end`, los
130 // hijos directos del nodo idx son los nodos h tales que h.parent == idx.
131 // Lo computamos: empezamos desde idx+1 y saltamos por subtree_end de
132 // cada hijo, hasta salir del rango.
133 let children = direct_children(mounted, idx);
134 if !children.is_empty() {
135 node.set_children(children.into_iter().map(node_id_for).collect::<Vec<_>>());
136 }
137
138 nodes.push((node_id_for(idx), node));
139 }
140
141 let focus = focused_idx
142 .map(node_id_for)
143 .unwrap_or(ROOT_NODE_ID);
144
145 TreeUpdate {
146 nodes,
147 tree: Some(Tree::new(ROOT_NODE_ID)),
148 focus,
149 tree_id,
150 }
151}
152
153/// Índices de los hijos directos del MountedNode `parent_idx`. Asume el
154/// recorrido pre-orden estándar del `mount`: el primer hijo está en
155/// `parent_idx + 1`; los siguientes se obtienen saltando por `subtree_end`.
156fn direct_children<Msg>(mounted: &Mounted<Msg>, parent_idx: usize) -> Vec<usize> {
157 let parent = &mounted.nodes[parent_idx];
158 let mut out = Vec::new();
159 let mut cursor = parent_idx + 1;
160 while cursor < parent.subtree_end {
161 out.push(cursor);
162 cursor = mounted.nodes[cursor].subtree_end;
163 }
164 out
165}
166
167/// Aplica los campos de un `SemanticsSpec` sobre un `accesskit::Node` recién
168/// creado (rol ya fijado). Mapea flags ARIA → setters AccessKit.
169fn apply_semantics(node: &mut Node, spec: &SemanticsSpec) {
170 if let Some(label) = &spec.label {
171 node.set_label(label.to_string());
172 }
173 if let Some(desc) = &spec.description {
174 node.set_description(desc.to_string());
175 }
176 if let Some(value) = &spec.value {
177 node.set_value(value.to_string());
178 }
179 // Flags. AccessKit usa `toggled` para checked/pressed (mismo enum); para
180 // expanded hay `set_expanded(bool)`; disabled = is_disabled flag.
181 if let Some(checked) = spec.flags.checked.or(spec.flags.pressed) {
182 node.set_toggled(if checked {
183 accesskit::Toggled::True
184 } else {
185 accesskit::Toggled::False
186 });
187 }
188 if let Some(expanded) = spec.flags.expanded {
189 node.set_expanded(expanded);
190 }
191 if spec.flags.disabled == Some(true) {
192 node.set_disabled();
193 }
194 if spec.flags.readonly == Some(true) {
195 node.set_read_only();
196 }
197 if spec.flags.required == Some(true) {
198 node.set_required();
199 }
200}
201
202/// Mapea un `Role` de Llimphi a un `accesskit::Role`. Para nodos sin
203/// `semantics` declarado, fallback a `Role::GenericContainer` (un grupo
204/// transparente que no aporta semántica propia pero permite que la jerarquía
205/// se navegue).
206fn map_role<Msg>(spec: &Option<SemanticsSpec>, _mn: &llimphi_compositor::MountedNode<Msg>) -> AkRole {
207 let Some(role) = spec.as_ref().and_then(|s| s.role) else {
208 return AkRole::GenericContainer;
209 };
210 match role {
211 LRole::Button => AkRole::Button,
212 LRole::TextInput => AkRole::TextInput,
213 LRole::Heading => AkRole::Heading,
214 LRole::Checkbox => AkRole::CheckBox,
215 LRole::Label => AkRole::Label,
216 LRole::Link => AkRole::Link,
217 LRole::MenuItem => AkRole::MenuItem,
218 LRole::Tab => AkRole::Tab,
219 LRole::Image => AkRole::Image,
220 LRole::Slider => AkRole::Slider,
221 LRole::Group => AkRole::Group,
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use llimphi_compositor::{mount, Role as LRole, View};
229 use llimphi_layout::taffy::prelude::length;
230 use llimphi_layout::taffy::Size;
231 use llimphi_layout::{LayoutTree, Style};
232
233 /// Monta un árbol con un nodo botón + un texto plano. Devuelve mounted +
234 /// computed contra un viewport razonable.
235 fn arbol_simple() -> (llimphi_compositor::Mounted<()>, ComputedLayout) {
236 let boton = View::<()>::new(Style {
237 size: Size { width: length(80.0_f32), height: length(40.0_f32) },
238 ..Default::default()
239 })
240 .role(LRole::Button)
241 .aria_label("Guardar");
242 let texto = View::<()>::new(Style::default()).text(
243 "Hola",
244 14.0,
245 llimphi_raster::peniko::Color::WHITE,
246 );
247 let raiz = View::<()>::new(Style::default()).children(vec![boton, texto]);
248 let mut layout = LayoutTree::new();
249 let mounted = mount(&mut layout, raiz);
250 let computed = layout
251 .compute(mounted.root, (1000.0_f32, 1000.0_f32))
252 .expect("layout");
253 (mounted, computed)
254 }
255
256 #[test]
257 fn build_tree_arma_raiz_y_un_node_por_mounted() {
258 let (m, c) = arbol_simple();
259 let tree = build_tree(&m, &c, None, "tawasuyu-test", TreeId(uuid::Uuid::nil()));
260 // root + 3 nodos (raíz View, boton, texto).
261 assert_eq!(tree.nodes.len(), 1 + m.nodes.len());
262 assert_eq!(tree.nodes[0].0, ROOT_NODE_ID);
263 // El segundo Node es el primer MountedNode (raíz del View).
264 assert_eq!(tree.nodes[1].0, node_id_for(0));
265 // Foco fallback = root sintético.
266 assert_eq!(tree.focus, ROOT_NODE_ID);
267 }
268
269 #[test]
270 fn boton_con_label_se_traduce_a_role_button() {
271 let (m, c) = arbol_simple();
272 let tree = build_tree(&m, &c, None, "test", TreeId(uuid::Uuid::nil()));
273 // El nodo con role=Button debería tener rol Button en accesskit.
274 let boton_node = tree
275 .nodes
276 .iter()
277 .find(|(_, n)| n.role() == AkRole::Button)
278 .expect("hay un Button");
279 assert_eq!(boton_node.1.label().as_deref(), Some("Guardar"));
280 }
281
282 #[test]
283 fn texto_sin_semantica_se_lee_como_label_del_node_generico() {
284 let (m, c) = arbol_simple();
285 let tree = build_tree(&m, &c, None, "test", TreeId(uuid::Uuid::nil()));
286 // Algún nodo con label "Hola" (el texto plano).
287 assert!(
288 tree.nodes
289 .iter()
290 .any(|(_, n)| n.label().as_deref() == Some("Hola")),
291 "el texto plano debería aparecer como label"
292 );
293 }
294
295 #[test]
296 fn foco_explicito_se_refleja_en_treeupdate_focus() {
297 let (m, c) = arbol_simple();
298 let tree = build_tree(&m, &c, Some(1), "test", TreeId(uuid::Uuid::nil()));
299 assert_eq!(tree.focus, node_id_for(1));
300 }
301
302 #[test]
303 fn mounted_idx_for_invierte_node_id_for() {
304 for i in 0..16 {
305 let nid = node_id_for(i);
306 assert_eq!(mounted_idx_for(nid), Some(i));
307 }
308 assert_eq!(mounted_idx_for(ROOT_NODE_ID), None);
309 }
310}