tawasuyu/shuma
Interactive shell with zsh/fish parity, native multiplexing + remote sessions, on Llimphi.
chore: refresco desde el monorepo (pin tawasuyu.git v0.3.0)
1// El **auditor de nodos mudos**: cuántos nodos interactivos de una app no se
2// pueden nombrar ni apuntar.
3//
4// Un nodo que se clickea y no dice qué es tiene dos consecuencias, y la
5// segunda es la que motiva este archivo:
6//
7// 1. Un lector de pantalla no puede anunciarlo (`MANUAL.md` §16.ter).
8// 2. **Nada externo puede apuntarle.** Una señalética anclada al árbol —un
9// marco, un letrero, un tour— necesita un selector estable; un nodo sin
10// rol ni nombre no tiene ninguno, y sólo se lo puede señalar por
11// coordenadas, que es justo lo que se rompe al cambiar el tamaño, el tema
12// o el idioma.
13//
14// ⚠️ **Sin rol NO es lo mismo que inalcanzable, y la primera versión de este
15// archivo (2026-08-29) los sumaba juntos.** Los widgets ponen el rol en la
16// FILA (`TreeItem`, `Row`, `ListItem`) y el `on_click` en un hijo, y
17// `a11y::click_target` resuelve ese desajuste delegando el click del nodo con
18// rol a su subárbol **si hay exactamente un descendiente clickeable**
19// (MANUAL §16.ter, «Activación»). Ese hijo no es deuda: su ancla es el padre,
20// que tiene rol y nombre. Contarlo con los mudos de verdad hacía que el
21// número dijera «98 % mudo» de un árbol donde cada fila se anuncia bien.
22//
23// Por eso la tabla separa tres poblaciones:
24// · **delegados** — sin rol, pero su ancestro con rol delega en ellos. Se
25// los apunta por el ancestro. No son deuda.
26// · **mudos** — sin rol y sin quien delegue: nada los puede señalar. Es la
27// deuda real, y es el número que hay que bajar.
28// · **anónimos** — de los mudos, los que además no tienen nombre accesible
29// (ni `aria_label` ni texto visible). A éstos no se les puede armar un
30// selector de ninguna forma; el arreglo pide escribir el texto, no sólo
31// poner el rol.
32//
33// Se incluye con `include!` desde el example `auditar_mudos` de cada app —el
34// mismo patrón que `escena_patron.rs`— para que una sola edición cambie el
35// criterio de todas a la vez y los números se comparen entre sí.
36
37use llimphi_test::llimphi_compositor::View;
38use llimphi_test::query::{NodeRef, Sel, Ui};
39
40/// Apaga lo que un `init` levanta y no le corresponde a un arnés. Se llama
41/// **antes** del primer `init`, desde `cabecera`.
42///
43/// Auditar por escenarios re-inicializa el modelo una vez por estado, y el
44/// `init` de las apps que hospedan shuma abre el servidor del IDE y deja un
45/// lockfile en el `~/.claude/ide` **real** del operador. Uno de más apaga la
46/// autoconexión de su próxima sesión (`claude --ide` conecta sólo si hay
47/// exactamente uno), así que treinta escenarios serían treinta formas de
48/// romperle el día a alguien que no está mirando. `SHUMA_IDE=0` es la válvula
49/// que el propio módulo documenta para esto (`shuma-shell-llimphi/src/ide.rs`).
50#[allow(dead_code)]
51fn arnes() {
52 // SAFETY: corre antes de que ningún escenario levante un hilo.
53 unsafe { std::env::set_var("SHUMA_IDE", "0") };
54 // Y el libro de autoría de `nakui` fuera del árbol. `NakuiApp::init` abre un
55 // backend de verdad: sin esto, cada corrida del auditor dejaba
56 // `nakui-ui-state.jsonl`, `nakui-ui-state.snap.json` y el directorio
57 // `nakui-historia/` en el **cwd**, que al correr `cargo run` es la raíz del
58 // repo. Aparecían como `??` en `git status` sin ningún gesto que los
59 // explicara — el mismo caso que `SHUMA_IDE`, con otro nombre.
60 // SAFETY: ídem.
61 unsafe {
62 std::env::set_var(
63 "NAKUI_EVENT_LOG",
64 std::env::temp_dir().join("auditar-mudos-nakui/estado.jsonl"),
65 )
66 };
67}
68
69/// Cuenta los candidatos a heredar la activación de un ancestro: clickeables
70/// **sin rol propio** del subárbol de `n`, `n` incluido. Un descendiente con rol
71/// no compite — ya es un nodo activable por su cuenta (ver `a11y::click_target`).
72fn candidatos_en<Msg: Clone + 'static>(n: &NodeRef<'_, Msg>) -> usize {
73 if n.role().is_some() {
74 return 0;
75 }
76 let propio = usize::from(n.is_clickable());
77 propio + n.children().iter().map(candidatos_en).sum::<usize>()
78}
79
80/// Recorre el árbol y devuelve los índices de los clickeables **sin rol** que
81/// un ancestro con rol alcanza por delegación — la regla de
82/// `a11y::click_target`: el ancestro no tiene handler propio, declara rol, y
83/// su subárbol tiene exactamente un clickeable.
84fn delegados<Msg: Clone + 'static>(n: &NodeRef<'_, Msg>, out: &mut Vec<usize>) {
85 let candidatos: usize = n.children().iter().map(candidatos_en).sum();
86 if !n.is_clickable() && n.role().is_some() && candidatos == 1 {
87 // Es el ancestro que delega: el único candidato de su subárbol queda
88 // alcanzable por él. Se sigue bajando igual, porque más abajo puede
89 // haber otro nodo con rol que delegue en lo suyo.
90 for h in n.children() {
91 marcar_unico(&h, out);
92 }
93 }
94 for h in n.children() {
95 delegados(&h, out);
96 }
97}
98
99/// Anota el único candidato del subárbol de `n` (mismo criterio que
100/// [`candidatos_en`]: se saltean las ramas que declaran rol propio).
101fn marcar_unico<Msg: Clone + 'static>(n: &NodeRef<'_, Msg>, out: &mut Vec<usize>) {
102 if n.role().is_some() {
103 return;
104 }
105 if n.is_clickable() {
106 out.push(n.idx());
107 return;
108 }
109 for h in n.children() {
110 marcar_unico(&h, out);
111 }
112}
113
114/// Monta `vista` a `w`×`h` y cuenta la deuda. Imprime una línea por app,
115/// tabulada, y devuelve `(interactivos, mudos, anonimos)`.
116fn auditar<Msg: Clone + 'static>(nombre: &str, vista: View<Msg>, w: f32, h: f32) -> (usize, usize, usize) {
117 let ui = Ui::new(vista, w, h);
118
119 let total = ui.len();
120 let interactivos = ui.find_all(Sel::new().clickable(true)).len();
121
122 let mut alcanzados = Vec::new();
123 delegados(&ui.root(), &mut alcanzados);
124
125 let sin_rol = ui.find_all(Sel::new().clickable(true).role_none());
126 let delegados_n = sin_rol.iter().filter(|n| alcanzados.contains(&n.idx())).count();
127 let mudos: Vec<_> = sin_rol
128 .into_iter()
129 .filter(|n| !alcanzados.contains(&n.idx()))
130 .collect();
131 let anonimos = mudos.iter().filter(|n| n.name().is_none()).count();
132
133 // Con `--volcado`, el árbol semántico entero (`Ui::dump`). Es lo que hay que
134 // mirar cuando un mudo no se deja identificar por su caja: el volcado dice
135 // quién es su padre, y ahí se ve de qué widget salió.
136 if std::env::args().any(|a| a == "--volcado") {
137 println!("{}", ui.dump());
138 }
139 // Con `--detail` se listan los mudos uno por uno: es lo que hay que leer
140 // para decidir si la deuda se salda con un rol o si además falta un nombre.
141 if std::env::args().any(|a| a == "--detail") {
142 for n in &mudos {
143 let r = n.rect();
144 println!(
145 " · {:<40} @{:.0},{:.0} {:.0}×{:.0}",
146 n.name().unwrap_or("‹anónimo›"),
147 r.x, r.y, r.w, r.h
148 );
149 }
150 }
151
152 let pct = if interactivos == 0 { 0.0 } else { 100.0 * mudos.len() as f32 / interactivos as f32 };
153 println!(
154 "{:<26} {:>7} {:>13} {:>10} {:>6} {:>7.0}% {:>9}",
155 nombre, total, interactivos, delegados_n, mudos.len(), pct, anonimos
156 );
157 (interactivos, mudos.len(), anonimos)
158}
159
160/// La cabecera de la tabla. La imprime el example antes de la primera fila.
161#[allow(dead_code)]
162fn cabecera() {
163 arnes();
164 println!(
165 "{:<26} {:>7} {:>13} {:>10} {:>6} {:>8} {:>9}",
166 "app", "nodos", "interactivos", "delegados", "mudos", "", "anónimos"
167 );
168}
169
170/// Audita **un estado que no es el del arranque**: inicializa el modelo, le
171/// aplica `pasos` en orden y recién ahí monta `view` (y `view_overlay`, si la
172/// app la implementa).
173///
174/// El cero del arranque no dice nada de un menú abierto: la mayor parte de lo
175/// que se clickea en `pata` o en `shuma` **no está en el árbol hasta que algo
176/// lo abre** (36 interactivos al arrancar contra 193 `on_click` en el código).
177/// Cada escenario es un `Vec<Msg>` porque llegar a un estado suele pedir más de
178/// un paso —abrir el panel y después entrar a una pestaña—, y porque un `Msg`
179/// es el mismo verbo que dispara el usuario: si el escenario se escribe con el
180/// mensaje real, no puede auditar un árbol que la app no sabe producir.
181///
182/// El modelo se re-inicializa por escenario a propósito: un panel abierto sobre
183/// los restos del anterior audita un árbol que el usuario no puede alcanzar.
184#[allow(dead_code)]
185fn escenario<A: llimphi_ui::App>(
186 nombre: &str,
187 hnd: &llimphi_ui::Handle<A::Msg>,
188 pasos: Vec<A::Msg>,
189 ancho: f32,
190 alto: f32,
191) -> (usize, usize, usize) {
192 let mut m = A::init(hnd);
193 for paso in pasos {
194 m = A::update(m, paso, hnd);
195 }
196 let mut acc = auditar(nombre, A::view(&m), ancho, alto);
197 if let Some(ov) = A::view_overlay(&m) {
198 let o = auditar(&format!("{nombre} ‹overlay›"), ov, ancho, alto);
199 acc = (acc.0 + o.0, acc.1 + o.1, acc.2 + o.2);
200 }
201 acc
202}
203
204/// Cierra la tabla con el total de las filas que le pasaron. Un total en cero
205/// es lo único que certifica que la deuda se saldó en TODOS los escenarios —
206/// leer fila por fila una tabla de treinta líneas es cómo se pasan por alto.
207#[allow(dead_code)]
208fn total(filas: &[(usize, usize, usize)]) {
209 let interactivos: usize = filas.iter().map(|f| f.0).sum();
210 let mudos: usize = filas.iter().map(|f| f.1).sum();
211 let anonimos: usize = filas.iter().map(|f| f.2).sum();
212 println!(
213 "{:-<26} {:>7} {:>13} {:>10} {:>6} {:>8} {:>9}",
214 "", "", interactivos, "", mudos, "", anonimos
215 );
216 println!(
217 "{} escenarios · {} interactivos · {} mudos · {} anónimos",
218 filas.len(),
219 interactivos,
220 mudos,
221 anonimos
222 );
223}
224
225/// Un `KeyEvent` de pulsación con los modificadores dados. Es el atajo escrito
226/// como lo escribe el usuario: `tecla(Key::Character("p".into()), true, true)`
227/// es Ctrl+Shift+P.
228#[allow(dead_code)]
229fn tecla(
230 key: llimphi_test::llimphi_compositor::Key,
231 ctrl: bool,
232 shift: bool,
233) -> llimphi_test::llimphi_compositor::KeyEvent {
234 use llimphi_test::llimphi_compositor::{KeyEvent, KeyState, Modifiers};
235 KeyEvent {
236 key,
237 state: KeyState::Pressed,
238 text: None,
239 modifiers: Modifiers { ctrl, shift, ..Default::default() },
240 repeat: false,
241 }
242}
243
244/// Como [`escenario`], pero llega al estado **por el atajo de teclado**, no por
245/// el `Msg`. Es la única puerta cuando lo que abre el panel es el `Msg` de un
246/// módulo cuyo tipo no es público (la paleta de comandos, el buscador en
247/// archivos, el terminal integrado de `nada`): el atajo es público porque lo
248/// tipea el usuario, y `App::on_key` traduce.
249///
250/// Una tecla que la app ignora no cambia nada y la fila sale igual a la del
251/// arranque — eso ya es información: significa que el atajo no está cableado.
252#[allow(dead_code)]
253fn escenario_teclas<A: llimphi_ui::App>(
254 nombre: &str,
255 hnd: &llimphi_ui::Handle<A::Msg>,
256 teclas: Vec<llimphi_test::llimphi_compositor::KeyEvent>,
257 ancho: f32,
258 alto: f32,
259) -> (usize, usize, usize) {
260 let mut m = A::init(hnd);
261 for k in teclas {
262 if let Some(msg) = A::on_key(&m, &k) {
263 m = A::update(m, msg, hnd);
264 }
265 }
266 let mut acc = auditar(nombre, A::view(&m), ancho, alto);
267 if let Some(ov) = A::view_overlay(&m) {
268 let o = auditar(&format!("{nombre} ‹overlay›"), ov, ancho, alto);
269 acc = (acc.0 + o.0, acc.1 + o.1, acc.2 + o.2);
270 }
271 acc
272}