tawasuyu

tawasuyu/llimphi

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

Rust 229TOML 101Markdown 82Shell 24052 Sutichaykuna

⎇ main widgets/help-menu/src/lib.rs Rust · 961 siq'ikuna · 33.2 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual d8edd9a9c · Sergio ·

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}