tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual
1//! `llimphi-widget-help-menu` — **el menú que abre `F1`**.
2//!
3//! Hasta hoy `F1` abría directamente el panel de atajos (`shortcuts-help`), y
4//! eso dejaba a la ayuda con una sola puerta: *«acá están las teclas»*. Pero la
5//! pregunta que trae alguien apretando `F1` no siempre es esa — a veces es
6//! «cómo se usa esto», a veces «algo anda mal», y a veces «qué versión estoy
7//! corriendo». Este widget es **la puerta**: un menú centrado con las entradas
8//! que la app declara, y un buscador encima.
9//!
10//! ## Las seis entradas canónicas
11//!
12//! [`HelpTopic`] las nombra con los mismos ids que ya usa el menú global de
13//! `app-bus` (`help.shortcuts`, `help.about`): no es un vocabulario nuevo, es
14//! el que ya estaba declarado en `AppMenu::standard()`. Una app prende las que
15//! puede servir y omite el resto — una entrada que no lleva a ningún lado es
16//! peor que no tenerla.
17//!
18//! Además de las seis, una app declara **sus propios índices**
19//! ([`HelpTopic::Index`]): shuma tiene el manual de sí misma *y* la navegación
20//! por las páginas de manual del sistema; nahual, los formatos que sabe abrir;
21//! tullpu, su catálogo de gestos. Son dos cosas distintas y por eso son dos
22//! entradas, no una.
23//!
24//! ## Tres decisiones que valen explicar
25//!
26//! **El buscador es un campo de texto de verdad** ([`llimphi_widget_text_input`]),
27//! no un `String` con un `|` pintado al final. Es la regla del input barato: un
28//! campo a mano pierde selección, undo, IME y el menú del botón derecho, y este
29//! es de los primeros que alguien estrena.
30//!
31//! **La vista no filtra.** Filtrar es una decisión del `update` (hay que saber
32//! qué se tipeó y guardar la selección), así que la vista recibe lo que hay que
33//! mostrar y ya. El filtro vive acá igual, como función pura
34//! ([`aplicar_filtro`]), para que no lo reescriba cada app y para poder
35//! certificarlo con texto en vez de con un pantallazo.
36//!
37//! **Lo que no entra se cuenta, no se esconde.** Si las entradas no entran en
38//! la pantalla, el menú se aprieta a un renglón por fila antes de recortar, y
39//! si aun así sobran, la última fila dice **cuántas** quedaron afuera. Una
40//! lista cortada en silencio se lee como «no hay más», que es exactamente el
41//! defecto que ya nos comió 25 de 57 apps en el cajón.
42
43#![forbid(unsafe_code)]
44#![cfg_attr(not(any(feature = "std", test)), no_std)]
45
46extern crate alloc;
47
48use alloc::string::{String, ToString};
49use alloc::sync::Arc;
50use alloc::vec;
51use alloc::vec::Vec;
52
53use llimphi_compositor::llimphi_layout::taffy::{
54 prelude::{auto, length, percent, FlexDirection, Position, Size, Style},
55 AlignItems, JustifyContent, Rect,
56};
57use llimphi_compositor::llimphi_text::Alignment;
58use llimphi_compositor::peniko::Color;
59use llimphi_compositor::{Role, View};
60use llimphi_theme::{alpha, radius, Theme};
61use llimphi_widget_panel::{panel_signature_painter, PanelStyle};
62use llimphi_widget_text_input::{
63 text_input_view_full, TextInputEvent, TextInputPalette, TextInputState,
64};
65
66// =====================================================================
67// Los temas — el vocabulario, compartido con `app-bus`
68// =====================================================================
69
70/// A dónde lleva una entrada del menú.
71///
72/// Los ids ([`HelpTopic::id`]) son los `command` del menú global de `app-bus`:
73/// una app que ya despacha `help.shortcuts` desde su barra no tiene que
74/// aprender un segundo nombre para lo mismo. Se tipean, así que van en inglés
75/// (regla 7); lo que el humano lee lo pone el caller ya traducido.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum HelpTopic {
78 /// Los atajos del contexto actual — el panel que ya existía
79 /// (`llimphi-widget-shortcuts-help`).
80 Shortcuts,
81 /// Cómo se usa la app. La fuente sana es **derivada** (se arma sola de los
82 /// atajos y de la señalética); la prosa escrita a mano la enmarca.
83 Manual,
84 /// El recorrido guiado, señalando los nodos de verdad de la pantalla.
85 Tour,
86 /// Preguntarle a la IA sobre esta app.
87 Ask,
88 /// Algo anda mal: arma el sobre de diagnóstico.
89 Report,
90 /// Qué es esto y qué versión está corriendo.
91 ///
92 /// **La versión que va acá es el sello, no `CARGO_PKG_VERSION`.** Ese
93 /// `0.1.0` es el mismo en los 1.043 crates del workspace y no se movió
94 /// nunca: parece un dato y no lo es. Lo que se pregunta de verdad —cuando
95 /// un deploy salió a medias, o cuando el reporte llega de otra máquina— es
96 /// de qué commit salió el binario. Para eso está `sello-version`:
97 ///
98 /// ```ignore
99 /// renglon("Versión", sello_version::con_fecha());
100 /// // 2026.06-eed3120b6 · 2026-09-18 (y «-sucio» si se compiló con cambios)
101 /// ```
102 ///
103 /// Es la misma forma que `publicar-suite.sh` escribe en
104 /// `dist/suite/VERSION`; la diferencia es que el sello **viaja adentro del
105 /// ejecutable**, que es donde hace falta.
106 About,
107 /// Un índice propio de la app (el `id` del `HelpIndex` que declaró).
108 Index(String),
109}
110
111impl HelpTopic {
112 /// El id estable, tal como viaja por el menú global y por la config.
113 pub fn id(&self) -> String {
114 match self {
115 HelpTopic::Shortcuts => "help.shortcuts".to_string(),
116 HelpTopic::Manual => "help.manual".to_string(),
117 HelpTopic::Tour => "help.tour".to_string(),
118 HelpTopic::Ask => "help.ask".to_string(),
119 HelpTopic::Report => "help.report".to_string(),
120 HelpTopic::About => "help.about".to_string(),
121 HelpTopic::Index(s) => {
122 let mut id = String::from("help.index.");
123 id.push_str(s);
124 id
125 }
126 }
127 }
128}
129
130// =====================================================================
131// Las entradas
132// =====================================================================
133
134/// Una fila del menú.
135#[derive(Debug, Clone)]
136pub struct HelpEntry<Msg> {
137 pub topic: HelpTopic,
138 /// El rótulo, **ya traducido** por el caller. El widget es `no_std` y no
139 /// conoce `rimay-localize` a propósito: el texto entra armado, igual que en
140 /// `shortcuts-help`.
141 pub label: String,
142 /// Una línea que dice qué es. Vacía = no se pinta el segundo renglón.
143 pub hint: String,
144 /// Glifo del gutter izquierdo. `None` = la fila arranca en el rótulo.
145 pub icon: Option<String>,
146 /// El atajo que lleva ahí directo, si lo tiene (`"Shift+F1"`).
147 pub keys: Option<String>,
148 /// Palabras por las que alguien la buscaría y **no** están en el rótulo:
149 /// «bug», «versión», «impresora». Sin esto el buscador sólo encuentra lo
150 /// que ya sabías cómo se llama, que es el caso en que no hacía falta.
151 pub keywords: Vec<String>,
152 /// Una entrada apagada se pinta en gris y **no despacha**: dice que existe
153 /// pero que en este contexto no lleva a ningún lado (ver [`HelpEntry::disabled`]).
154 pub enabled: bool,
155 pub on_activate: Msg,
156}
157
158impl<Msg> HelpEntry<Msg> {
159 pub fn new(topic: HelpTopic, label: impl Into<String>, on_activate: Msg) -> Self {
160 Self {
161 topic,
162 label: label.into(),
163 hint: String::new(),
164 icon: None,
165 keys: None,
166 keywords: Vec::new(),
167 enabled: true,
168 on_activate,
169 }
170 }
171
172 pub fn hint(mut self, s: impl Into<String>) -> Self {
173 self.hint = s.into();
174 self
175 }
176
177 pub fn icon(mut self, glyph: impl Into<String>) -> Self {
178 self.icon = Some(glyph.into());
179 self
180 }
181
182 pub fn keys(mut self, k: impl Into<String>) -> Self {
183 self.keys = Some(k.into());
184 self
185 }
186
187 pub fn keywords<I, S>(mut self, it: I) -> Self
188 where
189 I: IntoIterator<Item = S>,
190 S: Into<String>,
191 {
192 self.keywords = it.into_iter().map(Into::into).collect();
193 self
194 }
195
196 /// Existe pero hoy no lleva a ningún lado. **Se sigue viendo**: una entrada
197 /// que desaparece cuando no está disponible enseña que la ayuda es
198 /// impredecible; una en gris enseña que hay algo que todavía no se puede.
199 pub fn disabled(mut self) -> Self {
200 self.enabled = false;
201 self
202 }
203}
204
205// =====================================================================
206// El filtro — puro, y por eso certificable con texto
207// =====================================================================
208
209/// Minúsculas y **sin diacríticos**: quien busca «senaletica» tiene que
210/// encontrar «señalética», y quien escribe en un teclado sin acentos no se
211/// puede quedar afuera de su propia ayuda.
212fn plegado(s: &str) -> String {
213 let mut out = String::with_capacity(s.len());
214 for c in s.chars() {
215 let c = match c {
216 'á' | 'à' | 'ä' | 'â' | 'ã' | 'Á' | 'À' | 'Ä' | 'Â' | 'Ã' => 'a',
217 'é' | 'è' | 'ë' | 'ê' | 'É' | 'È' | 'Ë' | 'Ê' => 'e',
218 'í' | 'ì' | 'ï' | 'î' | 'Í' | 'Ì' | 'Ï' | 'Î' => 'i',
219 'ó' | 'ò' | 'ö' | 'ô' | 'õ' | 'Ó' | 'Ò' | 'Ö' | 'Ô' | 'Õ' => 'o',
220 'ú' | 'ù' | 'ü' | 'û' | 'Ú' | 'Ù' | 'Ü' | 'Û' => 'u',
221 'ñ' | 'Ñ' => 'n',
222 'ç' | 'Ç' => 'c',
223 otro => otro,
224 };
225 for m in c.to_lowercase() {
226 out.push(m);
227 }
228 }
229 out
230}
231
232/// `true` si los caracteres de `q` aparecen **en orden** dentro de `s`.
233/// Es lo que hace que «shrt» encuentre «Shortcuts» sin obligar a nadie a
234/// acertar la palabra entera.
235fn subsecuencia(s: &str, q: &str) -> bool {
236 let mut it = s.chars();
237 q.chars().all(|c| it.any(|d| d == c))
238}
239
240/// Cuánto matchea una entrada contra la consulta. `0` = no matchea.
241///
242/// El orden de las bandas es el orden en que uno espera los resultados: lo que
243/// **empieza** con lo que tipeaste primero, después lo que lo contiene, después
244/// la descripción, después las palabras clave, y por último el match laxo por
245/// subsecuencia. Una consulta vacía da `1` a todo: el menú completo, en el
246/// orden en que la app lo declaró.
247pub fn puntaje<Msg>(e: &HelpEntry<Msg>, q: &str) -> u32 {
248 let q = plegado(q);
249 let q = q.trim();
250 if q.is_empty() {
251 return 1;
252 }
253 let label = plegado(&e.label);
254 if label.starts_with(q) {
255 return 100;
256 }
257 if label.contains(q) {
258 return 80;
259 }
260 if !e.hint.is_empty() && plegado(&e.hint).contains(q) {
261 return 50;
262 }
263 if e.keywords.iter().any(|k| plegado(k).contains(q)) {
264 return 40;
265 }
266 if subsecuencia(&label, q) {
267 return 20;
268 }
269 0
270}
271
272/// Aplica el filtro y devuelve **las que quedan** más **cuántas se fueron**.
273///
274/// Devolver el descarte no es un detalle: es lo que le permite al menú decir
275/// «hay 4 más que no matchean» en vez de fingir que la app tiene tres entradas.
276pub fn aplicar_filtro<Msg: Clone>(
277 entries: &[HelpEntry<Msg>],
278 q: &str,
279) -> (Vec<HelpEntry<Msg>>, usize) {
280 let mut con_puntaje: Vec<(u32, usize)> = entries
281 .iter()
282 .enumerate()
283 .map(|(i, e)| (puntaje(e, q), i))
284 .filter(|(p, _)| *p > 0)
285 .collect();
286 // `sort_by` es **estable**: a igual puntaje gana el orden en que la app las
287 // declaró, que es el que el operador ya se aprendió de memoria.
288 con_puntaje.sort_by(|a, b| b.0.cmp(&a.0));
289 let fuera = entries.len() - con_puntaje.len();
290 (
291 con_puntaje
292 .into_iter()
293 .map(|(_, i)| entries[i].clone())
294 .collect(),
295 fuera,
296 )
297}
298
299// =====================================================================
300// La vista
301// =====================================================================
302
303/// Paleta del menú.
304#[derive(Debug, Clone)]
305pub struct HelpMenuPalette {
306 pub scrim: Color,
307 /// Firma del panel (gradient + hairline accent) — la misma que
308 /// `shortcuts-help` y el resto de los overlays.
309 pub panel: PanelStyle,
310 pub border: Color,
311 pub fg_title: Color,
312 pub fg_label: Color,
313 pub fg_hint: Color,
314 pub fg_disabled: Color,
315 pub fg_key: Color,
316 pub bg_key: Color,
317 pub bg_selected: Color,
318 /// Fondo de la fila bajo el puntero. Lo aplica `llimphi-ui` solo.
319 pub bg_hover: Color,
320 /// Tinte de la salpicadura del click. Semitransparente a propósito: es una
321 /// onda que se suma al fondo, no un color que lo reemplaza.
322 pub ripple: Color,
323 pub input: TextInputPalette,
324}
325
326impl HelpMenuPalette {
327 pub fn from_theme(t: &Theme) -> Self {
328 Self {
329 scrim: Color::from_rgba8(0, 0, 0, alpha::SCRIM),
330 panel: PanelStyle::from_theme_large(t),
331 border: t.border,
332 fg_title: t.fg_text,
333 fg_label: t.fg_text,
334 fg_hint: t.fg_placeholder,
335 fg_disabled: t.fg_placeholder,
336 fg_key: t.fg_text,
337 bg_key: t.bg_button,
338 bg_selected: t.bg_selected,
339 bg_hover: t.bg_button_hover,
340 ripple: Color::from_rgba8(255, 255, 255, 38),
341 input: TextInputPalette::from_theme(t),
342 }
343 }
344}
345
346/// El buscador. `None` en el spec = menú sin buscador (una app con tres
347/// entradas no necesita uno, y un campo que no filtra nada es ruido).
348pub struct HelpSearch<'a, Msg> {
349 /// El estado vive en el `Model` de la app — el widget es render-only.
350 pub state: &'a TextInputState,
351 pub placeholder: String,
352 pub focused: bool,
353 /// Envuelve los eventos del campo en el `Msg` de la app. Un solo mensaje
354 /// para todos los gestos (tecla, IME, click, arrastre): es lo que hace que
355 /// cablear un campo completo no cueste diez variantes de `Msg`.
356 pub wrap: Arc<dyn Fn(TextInputEvent) -> Msg + Send + Sync>,
357}
358
359/// Spec completo del menú.
360pub struct HelpMenuSpec<'a, Msg: Clone + 'static> {
361 pub title: String,
362 /// Las entradas **ya filtradas** (ver [`aplicar_filtro`]).
363 pub entries: Vec<HelpEntry<Msg>>,
364 /// Cuántas quedaron afuera del filtro. Se cuentan en el pie; no se
365 /// esconden.
366 pub hidden: usize,
367 /// Índice resaltado dentro de `entries` (la navegación con flechas la
368 /// lleva el `update` de la app, que es quien sabe qué tecla llegó).
369 pub selected: Option<usize>,
370 pub search: Option<HelpSearch<'a, Msg>>,
371 /// Qué decir cuando el filtro no dejó nada. Lo trae el caller porque es
372 /// texto que lee una persona y va en los tres idiomas.
373 pub empty_text: String,
374 /// Cómo redactar el pie de «hay N más». `None` = sólo el número (`+4`),
375 /// que no necesita traducción.
376 pub overflow_fmt: Option<Arc<dyn Fn(usize) -> String + Send + Sync>>,
377 pub viewport: (f32, f32),
378 /// Click en el scrim (fuera del panel) o `Esc`.
379 pub on_dismiss: Msg,
380 /// Click **adentro** del panel pero fuera de una fila. `None` = cierra,
381 /// como el context-menu. Una app con buscador debería mandar acá el mensaje
382 /// que **enfoca el campo**: si no, errarle a una fila por diez píxeles
383 /// cierra el menú y se lleva lo que la persona venía tipeando.
384 pub on_panel_click: Option<Msg>,
385 pub palette: HelpMenuPalette,
386}
387
388const PANEL_W: f32 = 520.0;
389const PAD: f32 = 20.0;
390const TITLE_H: f32 = 40.0;
391const SEARCH_H: f32 = 38.0;
392const SEARCH_GAP: f32 = 12.0;
393/// Fila con rótulo y descripción.
394const ROW_H: f32 = 46.0;
395/// Fila apretada: sólo el rótulo. Se usa **antes** de recortar — perder la
396/// descripción es barato, perder una entrada entera no.
397const ROW_H_COMPACTA: f32 = 30.0;
398const ICON_W: f32 = 30.0;
399/// Alto de la caja del glifo. Una línea: lo que se centra es esa caja.
400const ICON_LINEA: f32 = 18.0;
401const KEYS_W: f32 = 110.0;
402const LABEL_FONT: f32 = 13.5;
403const HINT_FONT: f32 = 11.5;
404const TITLE_FONT: f32 = 16.0;
405
406/// Cuántas filas entran y de qué alto — la aritmética del recorte, aparte de la
407/// vista para poder afirmarla en un test sin pintar nada.
408///
409/// Devuelve `(alto_de_fila, compacta, cuántas se muestran, cuántas van al pie)`.
410pub fn reparto(
411 filas: usize,
412 hidden: usize,
413 con_buscador: bool,
414 viewport_h: f32,
415) -> (f32, bool, usize, usize) {
416 let cabecera = TITLE_H + if con_buscador { SEARCH_H + SEARCH_GAP } else { 0.0 };
417 let disponible = (viewport_h - 32.0 - PAD * 2.0 - cabecera).max(ROW_H_COMPACTA * 2.0);
418 let necesarias = filas.max(1) + usize::from(hidden > 0);
419 // Primero se intenta con la fila alta; recién si no entra se aprieta.
420 let (row_h, compacta) = if necesarias as f32 * ROW_H <= disponible {
421 (ROW_H, false)
422 } else {
423 (ROW_H_COMPACTA, true)
424 };
425 let caben = ((disponible / row_h).floor() as usize).max(2);
426 if necesarias <= caben {
427 return (row_h, compacta, filas, hidden);
428 }
429 // No entran: se reserva la última fila para el conteo de lo que no se ve.
430 let mostradas = caben - 1;
431 (row_h, compacta, mostradas, filas - mostradas + hidden)
432}
433
434pub fn help_menu_view<Msg: Clone + 'static>(spec: HelpMenuSpec<'_, Msg>) -> View<Msg> {
435 let HelpMenuSpec {
436 title,
437 entries,
438 hidden,
439 selected,
440 search,
441 empty_text,
442 overflow_fmt,
443 viewport,
444 on_dismiss,
445 on_panel_click,
446 palette,
447 } = spec;
448
449 let con_buscador = search.is_some();
450 let (row_h, compacta, mostradas, al_pie) =
451 reparto(entries.len(), hidden, con_buscador, viewport.1);
452
453 let mut hijos: Vec<View<Msg>> = Vec::with_capacity(mostradas + 3);
454
455 hijos.push(
456 View::new(Style {
457 size: Size {
458 width: percent(1.0_f32),
459 height: length(TITLE_H),
460 },
461 align_items: Some(AlignItems::CENTER),
462 flex_shrink: 0.0,
463 ..Default::default()
464 })
465 .text_aligned(title.clone(), TITLE_FONT, palette.fg_title, Alignment::Start)
466 .role(Role::Heading),
467 );
468
469 if let Some(s) = search {
470 let wrap = s.wrap.clone();
471 let campo = text_input_view_full(
472 s.state,
473 &s.placeholder,
474 s.focused,
475 &palette.input,
476 move |ev| (wrap)(ev),
477 );
478 hijos.push(
479 View::new(Style {
480 size: Size {
481 width: percent(1.0_f32),
482 height: length(SEARCH_H),
483 },
484 margin: Rect {
485 left: length(0.0_f32),
486 right: length(0.0_f32),
487 top: length(0.0_f32),
488 bottom: length(SEARCH_GAP),
489 },
490 flex_shrink: 0.0,
491 ..Default::default()
492 })
493 .children(vec![campo]),
494 );
495 }
496
497 if entries.is_empty() {
498 hijos.push(
499 View::new(Style {
500 size: Size {
501 width: percent(1.0_f32),
502 height: length(row_h),
503 },
504 align_items: Some(AlignItems::CENTER),
505 flex_shrink: 0.0,
506 ..Default::default()
507 })
508 .text_aligned(
509 empty_text.clone(),
510 LABEL_FONT,
511 palette.fg_hint,
512 Alignment::Start,
513 )
514 .role(Role::Label)
515 .aria_label(empty_text),
516 );
517 } else {
518 let mut lista: Vec<View<Msg>> = Vec::with_capacity(mostradas);
519 for (i, e) in entries.iter().take(mostradas).enumerate() {
520 lista.push(fila_view(e, selected == Some(i), row_h, compacta, &palette));
521 }
522 hijos.push(
523 View::new(Style {
524 flex_direction: FlexDirection::Column,
525 size: Size {
526 width: percent(1.0_f32),
527 height: auto(),
528 },
529 flex_grow: 1.0,
530 ..Default::default()
531 })
532 .role(Role::Menu)
533 .children(lista),
534 );
535 }
536
537 if al_pie > 0 {
538 let texto = match &overflow_fmt {
539 Some(f) => f(al_pie),
540 None => {
541 let mut s = String::from("+");
542 s.push_str(&numero(al_pie));
543 s
544 }
545 };
546 hijos.push(
547 View::new(Style {
548 size: Size {
549 width: percent(1.0_f32),
550 height: length(row_h),
551 },
552 align_items: Some(AlignItems::CENTER),
553 flex_shrink: 0.0,
554 ..Default::default()
555 })
556 .text_aligned(texto.clone(), HINT_FONT, palette.fg_hint, Alignment::Start)
557 .role(Role::Label)
558 .aria_label(texto),
559 );
560 }
561
562 let alto_contenido = TITLE_H
563 + if con_buscador { SEARCH_H + SEARCH_GAP } else { 0.0 }
564 + (mostradas.max(1) + usize::from(al_pie > 0)) as f32 * row_h;
565 let panel_h = (alto_contenido + PAD * 2.0).min(viewport.1 - 32.0);
566 let panel_w = PANEL_W.min(viewport.0 - 32.0);
567 let x = ((viewport.0 - panel_w) * 0.5).max(0.0);
568 let y = ((viewport.1 - panel_h) * 0.5).max(0.0);
569
570 let mut panel = View::new(Style {
571 position: Position::Absolute,
572 inset: Rect {
573 left: length(x),
574 top: length(y),
575 right: auto(),
576 bottom: auto(),
577 },
578 size: Size {
579 width: length(panel_w),
580 height: length(panel_h),
581 },
582 flex_direction: FlexDirection::Column,
583 padding: Rect {
584 left: length(PAD),
585 right: length(PAD),
586 top: length(PAD),
587 bottom: length(PAD),
588 },
589 ..Default::default()
590 })
591 .paint_with(panel_signature_painter(palette.panel))
592 .radius(palette.panel.radius)
593 .clip(true)
594 // Modal sobre scrim: como `Dialog` el lector lo anuncia al abrirse y confina
595 // la navegación adentro.
596 .role(Role::Dialog)
597 .aria_label(title)
598 .children(hijos);
599 if let Some(m) = on_panel_click {
600 panel = panel.on_click(m);
601 }
602
603 View::new(Style {
604 size: Size {
605 width: percent(1.0_f32),
606 height: percent(1.0_f32),
607 },
608 ..Default::default()
609 })
610 .fill(palette.scrim)
611 .on_click(on_dismiss)
612 .children(vec![panel])
613}
614
615/// El número, sin `format!` (que arrastra el maquinario de formato al kernel
616/// por un entero de dos dígitos).
617fn numero(mut n: usize) -> String {
618 if n == 0 {
619 return String::from("0");
620 }
621 let mut d = Vec::new();
622 while n > 0 {
623 d.push(b'0' + (n % 10) as u8);
624 n /= 10;
625 }
626 d.reverse();
627 d.into_iter().map(|b| b as char).collect()
628}
629
630fn fila_view<Msg: Clone + 'static>(
631 e: &HelpEntry<Msg>,
632 seleccionada: bool,
633 row_h: f32,
634 compacta: bool,
635 palette: &HelpMenuPalette,
636) -> View<Msg> {
637 let fg = if e.enabled {
638 palette.fg_label
639 } else {
640 palette.fg_disabled
641 };
642 let mut celdas: Vec<View<Msg>> = Vec::with_capacity(3);
643
644 if let Some(g) = &e.icon {
645 // **El glifo va centrado a mano, en un hijo.** `align_items` centra a
646 // los HIJOS, y el texto propio de un nodo no es un hijo: se pinta desde
647 // arriba. Por eso los iconos salían pegados al rótulo en vez de al
648 // medio de la fila. La columna con `justify_content: CENTER` y el glifo
649 // adentro sí lo centra, y sigue funcionando cuando la fila cambia de
650 // alto (compacta / con descripción).
651 celdas.push(
652 View::new(Style {
653 flex_direction: FlexDirection::Column,
654 justify_content: Some(JustifyContent::CENTER),
655 size: Size {
656 width: length(ICON_W),
657 height: percent(1.0_f32),
658 },
659 flex_shrink: 0.0,
660 ..Default::default()
661 })
662 .children(vec![View::new(Style {
663 size: Size {
664 width: percent(1.0_f32),
665 height: length(ICON_LINEA),
666 },
667 ..Default::default()
668 })
669 .text_aligned(g.clone(), LABEL_FONT + 1.0, fg, Alignment::Start)]),
670 );
671 }
672
673 let mut centro: Vec<View<Msg>> = Vec::with_capacity(2);
674 centro.push(
675 View::new(Style {
676 size: Size {
677 width: percent(1.0_f32),
678 height: length(if compacta { row_h } else { 18.0 }),
679 },
680 align_items: Some(AlignItems::CENTER),
681 ..Default::default()
682 })
683 .text_aligned(e.label.clone(), LABEL_FONT, fg, Alignment::Start),
684 );
685 if !compacta && !e.hint.is_empty() {
686 centro.push(
687 View::new(Style {
688 size: Size {
689 width: percent(1.0_f32),
690 height: length(15.0_f32),
691 },
692 align_items: Some(AlignItems::CENTER),
693 ..Default::default()
694 })
695 .text_aligned(
696 e.hint.clone(),
697 HINT_FONT,
698 palette.fg_hint,
699 Alignment::Start,
700 ),
701 );
702 }
703 celdas.push(
704 View::new(Style {
705 flex_direction: FlexDirection::Column,
706 size: Size {
707 width: auto(),
708 height: auto(),
709 },
710 flex_grow: 1.0,
711 justify_content: Some(JustifyContent::CENTER),
712 ..Default::default()
713 })
714 .children(centro),
715 );
716
717 if let Some(k) = &e.keys {
718 celdas.push(
719 View::new(Style {
720 size: Size {
721 width: length(KEYS_W),
722 height: length(20.0_f32),
723 },
724 align_items: Some(AlignItems::CENTER),
725 justify_content: Some(JustifyContent::FLEX_END),
726 padding: Rect {
727 left: length(8.0_f32),
728 right: length(8.0_f32),
729 top: length(0.0_f32),
730 bottom: length(0.0_f32),
731 },
732 flex_shrink: 0.0,
733 ..Default::default()
734 })
735 .fill(palette.bg_key)
736 .radius(radius::XS)
737 .text_aligned(k.clone(), HINT_FONT, palette.fg_key, Alignment::End),
738 );
739 }
740
741 let mut fila = View::new(Style {
742 flex_direction: FlexDirection::Row,
743 size: Size {
744 width: percent(1.0_f32),
745 height: length(row_h),
746 },
747 align_items: Some(AlignItems::CENTER),
748 padding: Rect {
749 left: length(8.0_f32),
750 right: length(8.0_f32),
751 top: length(0.0_f32),
752 bottom: length(0.0_f32),
753 },
754 gap: Size {
755 width: length(8.0_f32),
756 height: length(0.0_f32),
757 },
758 flex_shrink: 0.0,
759 ..Default::default()
760 })
761 .role(Role::MenuItem)
762 .aria_label(e.label.clone())
763 .children(celdas);
764
765 if seleccionada {
766 fila = fila.fill(palette.bg_selected).radius(radius::SM);
767 }
768 // Sin `on_click` cuando está apagada: una fila en gris que igual despacha
769 // su mensaje es una mentira que se descubre clickeándola. Y sin hover ni
770 // salpicadura, por lo mismo: responder al mouse es prometer que hace algo.
771 if e.enabled {
772 fila = fila
773 .radius(radius::SM)
774 .hover_fill(palette.bg_hover)
775 // La salpicadura del click sale del punto tocado y se recorta al
776 // contorno. La `key` tiene que ser **estable entre rebuilds**, y la
777 // vista se rearma en cada cuadro: el índice de la fila no sirve
778 // porque el filtro la corre de lugar. El id del tema sí — es lo
779 // único que identifica a la entrada y no cambia con el orden.
780 .ripple(clave(&e.topic), palette.ripple)
781 .on_click(e.on_activate.clone());
782 }
783 fila
784}
785
786/// Una `key` estable para la salpicadura, derivada del id del tema.
787///
788/// Es un FNV-1a de tres líneas y no un `DefaultHasher` porque este crate es
789/// `no_std`: `std::collections::hash_map` no existe de este lado.
790fn clave(t: &HelpTopic) -> u64 {
791 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
792 for b in t.id().as_bytes() {
793 h ^= *b as u64;
794 h = h.wrapping_mul(0x0000_0100_0000_01b3);
795 }
796 h
797}
798
799// =====================================================================
800// Certificación — texto, no pantallazos (regla 8)
801// =====================================================================
802
803#[cfg(test)]
804mod tests {
805 use super::*;
806
807 #[derive(Debug, Clone, PartialEq)]
808 enum M {
809 Ir(u8),
810 }
811
812 fn e(topic: HelpTopic, label: &str) -> HelpEntry<M> {
813 HelpEntry::new(topic, label, M::Ir(0))
814 }
815
816 fn seis() -> Vec<HelpEntry<M>> {
817 vec![
818 e(HelpTopic::Shortcuts, "Atajos"),
819 e(HelpTopic::Manual, "Manual").hint("Cómo se usa esta app"),
820 e(HelpTopic::Tour, "Recorrido").hint("Te lo muestro en pantalla"),
821 e(HelpTopic::Ask, "Preguntarle a la IA"),
822 e(HelpTopic::Report, "Algo anda mal").keywords(["bug", "diagnostico", "reporte"]),
823 e(HelpTopic::About, "Acerca de").keywords(["version", "licencia"]),
824 ]
825 }
826
827 /// El vocabulario no es nuevo: es el que `app-bus` ya declara en
828 /// `AppMenu::standard()`. Si esto cambia, una app que despacha su menú
829 /// global deja de reconocer su propia entrada de ayuda.
830 #[test]
831 fn los_ids_son_los_del_menu_global() {
832 assert_eq!(HelpTopic::Shortcuts.id(), "help.shortcuts");
833 assert_eq!(HelpTopic::About.id(), "help.about");
834 assert_eq!(HelpTopic::Index("man".into()).id(), "help.index.man");
835 }
836
837 #[test]
838 fn consulta_vacia_deja_todo_en_el_orden_declarado() {
839 let (v, fuera) = aplicar_filtro(&seis(), "");
840 assert_eq!(fuera, 0);
841 assert_eq!(v.len(), 6);
842 assert_eq!(v[0].label, "Atajos");
843 assert_eq!(v[5].label, "Acerca de");
844 }
845
846 #[test]
847 fn el_prefijo_gana_a_lo_que_solo_contiene() {
848 let entradas = vec![
849 e(HelpTopic::Index("a".into()), "Ver el manual del sistema"),
850 e(HelpTopic::Manual, "Manual"),
851 ];
852 let (v, _) = aplicar_filtro(&entradas, "manual");
853 assert_eq!(v[0].label, "Manual");
854 }
855
856 /// Quien tipea sin acentos no se puede quedar afuera de su propia ayuda.
857 #[test]
858 fn sin_acentos_encuentra_lo_acentuado() {
859 let entradas = vec![e(HelpTopic::Tour, "Señalética")];
860 let (v, fuera) = aplicar_filtro(&entradas, "senaletica");
861 assert_eq!(fuera, 0);
862 assert_eq!(v.len(), 1);
863 }
864
865 /// El caso que justifica que existan las palabras clave: nadie busca
866 /// «Algo anda mal» — busca «bug».
867 #[test]
868 fn la_palabra_clave_encuentra_lo_que_el_rotulo_no_dice() {
869 let (v, fuera) = aplicar_filtro(&seis(), "bug");
870 assert_eq!(v.len(), 1);
871 assert_eq!(v[0].label, "Algo anda mal");
872 assert_eq!(fuera, 5);
873 }
874
875 #[test]
876 fn la_subsecuencia_perdona_no_saber_la_palabra_entera() {
877 let (v, _) = aplicar_filtro(&seis(), "atjs");
878 assert_eq!(v[0].label, "Atajos");
879 }
880
881 /// Sin resultados, el descarte se **cuenta**: el panel puede decir que hay
882 /// seis entradas que no matchean en vez de fingir que la app no tiene
883 /// ayuda.
884 #[test]
885 fn lo_que_no_matchea_se_cuenta() {
886 let (v, fuera) = aplicar_filtro(&seis(), "zzz");
887 assert!(v.is_empty());
888 assert_eq!(fuera, 6);
889 }
890
891 #[test]
892 fn con_espacio_entra_la_fila_alta_y_nada_va_al_pie() {
893 let (h, compacta, mostradas, pie) = reparto(6, 0, true, 800.0);
894 assert_eq!(h, ROW_H);
895 assert!(!compacta);
896 assert_eq!(mostradas, 6);
897 assert_eq!(pie, 0);
898 }
899
900 /// Primero se pierde la descripción; recién después se recorta. Perder un
901 /// renglón de ayuda es barato, perder una entrada entera no.
902 #[test]
903 fn se_aprieta_antes_de_recortar() {
904 let (h, compacta, mostradas, pie) = reparto(10, 0, true, 480.0);
905 assert_eq!(h, ROW_H_COMPACTA);
906 assert!(compacta);
907 assert_eq!(mostradas, 10);
908 assert_eq!(pie, 0);
909 }
910
911 /// Y cuando ni apretado entra, lo que no se ve **se cuenta**. Una lista
912 /// cortada en silencio se lee como «no hay más».
913 #[test]
914 fn lo_que_no_entra_va_al_pie_y_no_desaparece() {
915 let (_, _, mostradas, pie) = reparto(20, 3, true, 300.0);
916 assert!(mostradas < 20);
917 assert_eq!(mostradas + pie, 23, "no se pierde ninguna por el camino");
918 }
919
920 #[test]
921 fn numero_sin_format() {
922 assert_eq!(numero(0), "0");
923 assert_eq!(numero(7), "7");
924 assert_eq!(numero(142), "142");
925 }
926
927 /// Humo: armar la vista no paniquea ni con el menú vacío ni con el lleno.
928 #[test]
929 fn la_vista_se_arma() {
930 let (entries, hidden) = aplicar_filtro(&seis(), "");
931 let spec = HelpMenuSpec {
932 title: "Ayuda".into(),
933 entries,
934 hidden,
935 selected: Some(0),
936 search: None,
937 empty_text: "Nada con ese nombre".into(),
938 overflow_fmt: None,
939 viewport: (1280.0, 800.0),
940 on_dismiss: M::Ir(9),
941 on_panel_click: None,
942 palette: HelpMenuPalette::from_theme(&Theme::dark()),
943 };
944 let _ = help_menu_view(spec);
945
946 let spec = HelpMenuSpec {
947 title: "Ayuda".into(),
948 entries: Vec::new(),
949 hidden: 6,
950 selected: None,
951 search: None,
952 empty_text: "Nada con ese nombre".into(),
953 overflow_fmt: None,
954 viewport: (320.0, 240.0),
955 on_dismiss: M::Ir(9),
956 on_panel_click: None,
957 palette: HelpMenuPalette::from_theme(&Theme::light()),
958 };
959 let _ = help_menu_view(spec);
960 }
961}