gioser

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

⎇ 993b7625b84f5f10b70866e0497e80de22aaa880 llimphi-ui/src/a11y.rs Rust · 310 siq'ikuna · 12.0 KB Ñawpa willay Pi imata llamkarqan ch'uya

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

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}