llimphi_compositor/semantics.rs
1//! Modelo de **semántica accesible** de un nodo. Es el dato que el runtime
2//! traduce a un árbol [AccessKit](https://accesskit.dev) por frame para
3//! alimentar lectores de pantalla (NVDA, VoiceOver, Orca, TalkBack) y otras
4//! ayudas técnicas — TTS, navegación por voz, switch control.
5//!
6//! Este módulo es **pura data**: define los tipos sin acoplarse al crate
7//! `accesskit`. La conversión a `accesskit::Node` vive en `llimphi-ui::a11y`
8//! (iter 2 del plan), donde el cableado del adapter winit ya importa la
9//! librería. Tener acá solo el modelo permite:
10//!
11//! - Compilar el compositor con o sin la integración AccessKit habilitada.
12//! - Testear semántica a nivel "qué declaran los widgets" sin levantar un
13//! adapter ni un lector real.
14//! - Mantener la API estable aunque cambien versiones de `accesskit`.
15//!
16//! ## Cuándo declarar semántica
17//!
18//! - **Siempre** en controles interactivos: botones, inputs, checkboxes, tabs,
19//! ítems de menú, sliders. Sin rol declarado, el lector no sabe que el nodo
20//! ES un botón aunque tenga `on_click`.
21//! - **Para texto significativo** que no es un botón: títulos (`Heading`),
22//! etiquetas asociadas, valores (`Label` / `Static`). El text de un nodo se
23//! lee igual aunque no tenga `semantics`, pero un rol explícito mejora la
24//! navegación por rol de los lectores.
25//! - **Para grouping**: tabbar, dock, toolbars, listas — `Role::Group` o un
26//! rol específico (`TabList`, `Menu`, `Toolbar`) ayuda a saltar bloques.
27//!
28//! ## Cuándo NO declarar
29//!
30//! Decorativo puro (un divider, un fondo con gradiente, una sombra) **no debe**
31//! declarar semántica — los lectores ya filtran texto vacío, pero un rol
32//! superfluo (`Role::Group` en cada `View` envoltorio) ensucia la navegación.
33
34use std::sync::Arc;
35
36/// Rol semántico del nodo. Los nombres y la granularidad siguen los roles de
37/// AccessKit / ARIA. Subset acotado: agregamos lo que falte cuando aparezca un
38/// caller real (regla del repo — no diseñamos para lo hipotético).
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
40pub enum Role {
41 /// Botón clickeable. El lector dice "botón <label>" + el flag `pressed`
42 /// para botones de toggle.
43 Button,
44 /// Campo de texto editable (single-line o multi-line). Combinable con
45 /// `value` (texto actual) y los flags `readonly`/`required`.
46 TextInput,
47 /// Título de sección (h1..h6 en HTML). El `value` puede llevar el nivel
48 /// como string ("1", "2", …) si la app lo necesita; v1 no lo distingue.
49 Heading,
50 /// Casilla de verificación. Combina con `checked`.
51 Checkbox,
52 /// Texto estático significativo (no interactivo, no título). Si solo es
53 /// decorativo, no declarar semántica.
54 Label,
55 /// Hipervínculo / acción que navega a otra ubicación.
56 Link,
57 /// Ítem de un menú (context-menu, menubar, dropdown).
58 MenuItem,
59 /// Pestaña de un tabbar / segmented control.
60 Tab,
61 /// Imagen significativa. El `label` actúa como alt-text.
62 Image,
63 /// Control deslizable continuo (volumen, brillo, range). Combinable con
64 /// `value` (string del valor actual) — los rangos numéricos se modelan
65 /// más fino en iter posteriores si hace falta.
66 Slider,
67 /// Agrupador genérico (toolbar, panel, sección). Sirve para que los
68 /// lectores ofrezcan "saltar al siguiente grupo".
69 Group,
70}
71
72/// Banderas booleanas del nodo accesible. Todas opcionales (`None` = no aplica,
73/// que es distinto de "aplica pero es false"). Mantienelas en None salvo que el
74/// widget realmente las exponga — los lectores diferencian "no es checkable" de
75/// "es checkable y no checked".
76#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
77pub struct SemanticsFlags {
78 /// Estado de un checkbox / radio / toggle button.
79 pub checked: Option<bool>,
80 /// Estado on/off de un botón de toggle (separado de `checked` porque ARIA
81 /// los distingue: un toggle `<button>` usa `aria-pressed`, una checkbox
82 /// `aria-checked`).
83 pub pressed: Option<bool>,
84 /// Para acordeones, menús, tree-rows que se expanden.
85 pub expanded: Option<bool>,
86 /// El control está deshabilitado (no responde a input).
87 pub disabled: Option<bool>,
88 /// Sólo lectura (típicamente input de texto que no se edita).
89 pub readonly: Option<bool>,
90 /// Campo requerido (formularios).
91 pub required: Option<bool>,
92}
93
94impl SemanticsFlags {
95 pub const EMPTY: Self = Self {
96 checked: None,
97 pressed: None,
98 expanded: None,
99 disabled: None,
100 readonly: None,
101 required: None,
102 };
103}
104
105/// Especificación semántica completa de un nodo. Lo que el runtime traduce a
106/// un `accesskit::Node` cada frame.
107///
108/// `label` es lo que el lector enuncia primero (el "nombre accesible"). Si el
109/// nodo ya tiene un `text` visible y significativo, podés dejar `label = None`
110/// y el runtime usará ese texto como nombre — pero declararlo explícito es más
111/// robusto (e.g. un botón con sólo un ícono necesita label porque no hay texto
112/// visible).
113///
114/// `value` es el dato dinámico (texto del input, valor del slider). El lector
115/// suele leer label + value juntos: "Volumen, 70".
116///
117/// `description` es contexto adicional ("Disminuye el volumen del sistema").
118/// Los lectores lo leen tras una pausa o con un atajo distinto; usalo para
119/// info que ayude PERO no sobreloadées (los usuarios de TTS perciben ruido
120/// más que falta de info).
121#[derive(Clone, Debug, Default, PartialEq)]
122pub struct SemanticsSpec {
123 pub role: Option<Role>,
124 pub label: Option<Arc<str>>,
125 pub description: Option<Arc<str>>,
126 pub value: Option<Arc<str>>,
127 pub flags: SemanticsFlags,
128}
129
130impl SemanticsSpec {
131 /// Especificación con sólo el rol fijado. Atajo común; los demás campos
132 /// quedan `None` y los flags vacíos.
133 pub fn role(role: Role) -> Self {
134 Self {
135 role: Some(role),
136 ..Self::default()
137 }
138 }
139
140 /// Pone `label` (consumiendo cualquier valor previo).
141 pub fn with_label(mut self, s: impl Into<Arc<str>>) -> Self {
142 self.label = Some(s.into());
143 self
144 }
145
146 /// Pone `description`.
147 pub fn with_description(mut self, s: impl Into<Arc<str>>) -> Self {
148 self.description = Some(s.into());
149 self
150 }
151
152 /// Pone `value`.
153 pub fn with_value(mut self, s: impl Into<Arc<str>>) -> Self {
154 self.value = Some(s.into());
155 self
156 }
157
158 /// Pone `flags.checked = Some(v)`.
159 pub fn with_checked(mut self, v: bool) -> Self {
160 self.flags.checked = Some(v);
161 self
162 }
163 pub fn with_pressed(mut self, v: bool) -> Self {
164 self.flags.pressed = Some(v);
165 self
166 }
167 pub fn with_expanded(mut self, v: bool) -> Self {
168 self.flags.expanded = Some(v);
169 self
170 }
171 pub fn with_disabled(mut self, v: bool) -> Self {
172 self.flags.disabled = Some(v);
173 self
174 }
175 pub fn with_readonly(mut self, v: bool) -> Self {
176 self.flags.readonly = Some(v);
177 self
178 }
179 pub fn with_required(mut self, v: bool) -> Self {
180 self.flags.required = Some(v);
181 self
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 #[test]
190 fn default_es_todo_none_y_flags_empty() {
191 let s = SemanticsSpec::default();
192 assert!(s.role.is_none());
193 assert!(s.label.is_none());
194 assert!(s.value.is_none());
195 assert_eq!(s.flags, SemanticsFlags::EMPTY);
196 }
197
198 #[test]
199 fn role_builder_pone_solo_el_rol() {
200 let s = SemanticsSpec::role(Role::Button);
201 assert_eq!(s.role, Some(Role::Button));
202 assert!(s.label.is_none());
203 assert!(s.value.is_none());
204 assert_eq!(s.flags, SemanticsFlags::EMPTY);
205 }
206
207 #[test]
208 fn with_label_y_with_value_componen() {
209 let s = SemanticsSpec::role(Role::Slider)
210 .with_label("Volumen")
211 .with_value("70");
212 assert_eq!(s.role, Some(Role::Slider));
213 assert_eq!(s.label.as_deref(), Some("Volumen"));
214 assert_eq!(s.value.as_deref(), Some("70"));
215 }
216
217 #[test]
218 fn flags_con_with_son_independientes() {
219 let s = SemanticsSpec::role(Role::Checkbox)
220 .with_checked(true)
221 .with_required(true);
222 assert_eq!(s.flags.checked, Some(true));
223 assert_eq!(s.flags.required, Some(true));
224 assert!(s.flags.disabled.is_none(), "no setear flags no tocados");
225 }
226}