//! `llimphi-widget-help-menu` — **el menú que abre `F1`**. //! //! Hasta hoy `F1` abría directamente el panel de atajos (`shortcuts-help`), y //! eso dejaba a la ayuda con una sola puerta: *«acá están las teclas»*. Pero la //! pregunta que trae alguien apretando `F1` no siempre es esa — a veces es //! «cómo se usa esto», a veces «algo anda mal», y a veces «qué versión estoy //! corriendo». Este widget es **la puerta**: un menú centrado con las entradas //! que la app declara, y un buscador encima. //! //! ## Las seis entradas canónicas //! //! [`HelpTopic`] las nombra con los mismos ids que ya usa el menú global de //! `app-bus` (`help.shortcuts`, `help.about`): no es un vocabulario nuevo, es //! el que ya estaba declarado en `AppMenu::standard()`. Una app prende las que //! puede servir y omite el resto — una entrada que no lleva a ningún lado es //! peor que no tenerla. //! //! Además de las seis, una app declara **sus propios índices** //! ([`HelpTopic::Index`]): shuma tiene el manual de sí misma *y* la navegación //! por las páginas de manual del sistema; nahual, los formatos que sabe abrir; //! tullpu, su catálogo de gestos. Son dos cosas distintas y por eso son dos //! entradas, no una. //! //! ## Tres decisiones que valen explicar //! //! **El buscador es un campo de texto de verdad** ([`llimphi_widget_text_input`]), //! no un `String` con un `|` pintado al final. Es la regla del input barato: un //! campo a mano pierde selección, undo, IME y el menú del botón derecho, y este //! es de los primeros que alguien estrena. //! //! **La vista no filtra.** Filtrar es una decisión del `update` (hay que saber //! qué se tipeó y guardar la selección), así que la vista recibe lo que hay que //! mostrar y ya. El filtro vive acá igual, como función pura //! ([`aplicar_filtro`]), para que no lo reescriba cada app y para poder //! certificarlo con texto en vez de con un pantallazo. //! //! **Lo que no entra se cuenta, no se esconde.** Si las entradas no entran en //! la pantalla, el menú se aprieta a un renglón por fila antes de recortar, y //! si aun así sobran, la última fila dice **cuántas** quedaron afuera. Una //! lista cortada en silencio se lee como «no hay más», que es exactamente el //! defecto que ya nos comió 25 de 57 apps en el cajón. #![forbid(unsafe_code)] #![cfg_attr(not(any(feature = "std", test)), no_std)] extern crate alloc; use alloc::string::{String, ToString}; use alloc::sync::Arc; use alloc::vec; use alloc::vec::Vec; use llimphi_compositor::llimphi_layout::taffy::{ prelude::{auto, length, percent, FlexDirection, Position, Size, Style}, AlignItems, JustifyContent, Rect, }; use llimphi_compositor::llimphi_text::Alignment; use llimphi_compositor::peniko::Color; use llimphi_compositor::{Role, View}; use llimphi_theme::{alpha, radius, Theme}; use llimphi_widget_panel::{panel_signature_painter, PanelStyle}; use llimphi_widget_text_input::{ text_input_view_full, TextInputEvent, TextInputPalette, TextInputState, }; // ===================================================================== // Los temas — el vocabulario, compartido con `app-bus` // ===================================================================== /// A dónde lleva una entrada del menú. /// /// Los ids ([`HelpTopic::id`]) son los `command` del menú global de `app-bus`: /// una app que ya despacha `help.shortcuts` desde su barra no tiene que /// aprender un segundo nombre para lo mismo. Se tipean, así que van en inglés /// (regla 7); lo que el humano lee lo pone el caller ya traducido. #[derive(Debug, Clone, PartialEq, Eq)] pub enum HelpTopic { /// Los atajos del contexto actual — el panel que ya existía /// (`llimphi-widget-shortcuts-help`). Shortcuts, /// Cómo se usa la app. La fuente sana es **derivada** (se arma sola de los /// atajos y de la señalética); la prosa escrita a mano la enmarca. Manual, /// El recorrido guiado, señalando los nodos de verdad de la pantalla. Tour, /// Preguntarle a la IA sobre esta app. Ask, /// Algo anda mal: arma el sobre de diagnóstico. Report, /// Qué es esto y qué versión está corriendo. /// /// **La versión que va acá es el sello, no `CARGO_PKG_VERSION`.** Ese /// `0.1.0` es el mismo en los 1.043 crates del workspace y no se movió /// nunca: parece un dato y no lo es. Lo que se pregunta de verdad —cuando /// un deploy salió a medias, o cuando el reporte llega de otra máquina— es /// de qué commit salió el binario. Para eso está `sello-version`: /// /// ```ignore /// renglon("Versión", sello_version::con_fecha()); /// // 2026.06-eed3120b6 · 2026-09-18 (y «-sucio» si se compiló con cambios) /// ``` /// /// Es la misma forma que `publicar-suite.sh` escribe en /// `dist/suite/VERSION`; la diferencia es que el sello **viaja adentro del /// ejecutable**, que es donde hace falta. About, /// Un índice propio de la app (el `id` del `HelpIndex` que declaró). Index(String), } impl HelpTopic { /// El id estable, tal como viaja por el menú global y por la config. pub fn id(&self) -> String { match self { HelpTopic::Shortcuts => "help.shortcuts".to_string(), HelpTopic::Manual => "help.manual".to_string(), HelpTopic::Tour => "help.tour".to_string(), HelpTopic::Ask => "help.ask".to_string(), HelpTopic::Report => "help.report".to_string(), HelpTopic::About => "help.about".to_string(), HelpTopic::Index(s) => { let mut id = String::from("help.index."); id.push_str(s); id } } } } // ===================================================================== // Las entradas // ===================================================================== /// Una fila del menú. #[derive(Debug, Clone)] pub struct HelpEntry { pub topic: HelpTopic, /// El rótulo, **ya traducido** por el caller. El widget es `no_std` y no /// conoce `rimay-localize` a propósito: el texto entra armado, igual que en /// `shortcuts-help`. pub label: String, /// Una línea que dice qué es. Vacía = no se pinta el segundo renglón. pub hint: String, /// Glifo del gutter izquierdo. `None` = la fila arranca en el rótulo. pub icon: Option, /// El atajo que lleva ahí directo, si lo tiene (`"Shift+F1"`). pub keys: Option, /// Palabras por las que alguien la buscaría y **no** están en el rótulo: /// «bug», «versión», «impresora». Sin esto el buscador sólo encuentra lo /// que ya sabías cómo se llama, que es el caso en que no hacía falta. pub keywords: Vec, /// Una entrada apagada se pinta en gris y **no despacha**: dice que existe /// pero que en este contexto no lleva a ningún lado (ver [`HelpEntry::disabled`]). pub enabled: bool, pub on_activate: Msg, } impl HelpEntry { pub fn new(topic: HelpTopic, label: impl Into, on_activate: Msg) -> Self { Self { topic, label: label.into(), hint: String::new(), icon: None, keys: None, keywords: Vec::new(), enabled: true, on_activate, } } pub fn hint(mut self, s: impl Into) -> Self { self.hint = s.into(); self } pub fn icon(mut self, glyph: impl Into) -> Self { self.icon = Some(glyph.into()); self } pub fn keys(mut self, k: impl Into) -> Self { self.keys = Some(k.into()); self } pub fn keywords(mut self, it: I) -> Self where I: IntoIterator, S: Into, { self.keywords = it.into_iter().map(Into::into).collect(); self } /// Existe pero hoy no lleva a ningún lado. **Se sigue viendo**: una entrada /// que desaparece cuando no está disponible enseña que la ayuda es /// impredecible; una en gris enseña que hay algo que todavía no se puede. pub fn disabled(mut self) -> Self { self.enabled = false; self } } // ===================================================================== // El filtro — puro, y por eso certificable con texto // ===================================================================== /// Minúsculas y **sin diacríticos**: quien busca «senaletica» tiene que /// encontrar «señalética», y quien escribe en un teclado sin acentos no se /// puede quedar afuera de su propia ayuda. fn plegado(s: &str) -> String { let mut out = String::with_capacity(s.len()); for c in s.chars() { let c = match c { 'á' | 'à' | 'ä' | 'â' | 'ã' | 'Á' | 'À' | 'Ä' | 'Â' | 'Ã' => 'a', 'é' | 'è' | 'ë' | 'ê' | 'É' | 'È' | 'Ë' | 'Ê' => 'e', 'í' | 'ì' | 'ï' | 'î' | 'Í' | 'Ì' | 'Ï' | 'Î' => 'i', 'ó' | 'ò' | 'ö' | 'ô' | 'õ' | 'Ó' | 'Ò' | 'Ö' | 'Ô' | 'Õ' => 'o', 'ú' | 'ù' | 'ü' | 'û' | 'Ú' | 'Ù' | 'Ü' | 'Û' => 'u', 'ñ' | 'Ñ' => 'n', 'ç' | 'Ç' => 'c', otro => otro, }; for m in c.to_lowercase() { out.push(m); } } out } /// `true` si los caracteres de `q` aparecen **en orden** dentro de `s`. /// Es lo que hace que «shrt» encuentre «Shortcuts» sin obligar a nadie a /// acertar la palabra entera. fn subsecuencia(s: &str, q: &str) -> bool { let mut it = s.chars(); q.chars().all(|c| it.any(|d| d == c)) } /// Cuánto matchea una entrada contra la consulta. `0` = no matchea. /// /// El orden de las bandas es el orden en que uno espera los resultados: lo que /// **empieza** con lo que tipeaste primero, después lo que lo contiene, después /// la descripción, después las palabras clave, y por último el match laxo por /// subsecuencia. Una consulta vacía da `1` a todo: el menú completo, en el /// orden en que la app lo declaró. pub fn puntaje(e: &HelpEntry, q: &str) -> u32 { let q = plegado(q); let q = q.trim(); if q.is_empty() { return 1; } let label = plegado(&e.label); if label.starts_with(q) { return 100; } if label.contains(q) { return 80; } if !e.hint.is_empty() && plegado(&e.hint).contains(q) { return 50; } if e.keywords.iter().any(|k| plegado(k).contains(q)) { return 40; } if subsecuencia(&label, q) { return 20; } 0 } /// Aplica el filtro y devuelve **las que quedan** más **cuántas se fueron**. /// /// Devolver el descarte no es un detalle: es lo que le permite al menú decir /// «hay 4 más que no matchean» en vez de fingir que la app tiene tres entradas. pub fn aplicar_filtro( entries: &[HelpEntry], q: &str, ) -> (Vec>, usize) { let mut con_puntaje: Vec<(u32, usize)> = entries .iter() .enumerate() .map(|(i, e)| (puntaje(e, q), i)) .filter(|(p, _)| *p > 0) .collect(); // `sort_by` es **estable**: a igual puntaje gana el orden en que la app las // declaró, que es el que el operador ya se aprendió de memoria. con_puntaje.sort_by(|a, b| b.0.cmp(&a.0)); let fuera = entries.len() - con_puntaje.len(); ( con_puntaje .into_iter() .map(|(_, i)| entries[i].clone()) .collect(), fuera, ) } // ===================================================================== // La vista // ===================================================================== /// Paleta del menú. #[derive(Debug, Clone)] pub struct HelpMenuPalette { pub scrim: Color, /// Firma del panel (gradient + hairline accent) — la misma que /// `shortcuts-help` y el resto de los overlays. pub panel: PanelStyle, pub border: Color, pub fg_title: Color, pub fg_label: Color, pub fg_hint: Color, pub fg_disabled: Color, pub fg_key: Color, pub bg_key: Color, pub bg_selected: Color, /// Fondo de la fila bajo el puntero. Lo aplica `llimphi-ui` solo. pub bg_hover: Color, /// Tinte de la salpicadura del click. Semitransparente a propósito: es una /// onda que se suma al fondo, no un color que lo reemplaza. pub ripple: Color, pub input: TextInputPalette, } impl HelpMenuPalette { pub fn from_theme(t: &Theme) -> Self { Self { scrim: Color::from_rgba8(0, 0, 0, alpha::SCRIM), panel: PanelStyle::from_theme_large(t), border: t.border, fg_title: t.fg_text, fg_label: t.fg_text, fg_hint: t.fg_placeholder, fg_disabled: t.fg_placeholder, fg_key: t.fg_text, bg_key: t.bg_button, bg_selected: t.bg_selected, bg_hover: t.bg_button_hover, ripple: Color::from_rgba8(255, 255, 255, 38), input: TextInputPalette::from_theme(t), } } } /// El buscador. `None` en el spec = menú sin buscador (una app con tres /// entradas no necesita uno, y un campo que no filtra nada es ruido). pub struct HelpSearch<'a, Msg> { /// El estado vive en el `Model` de la app — el widget es render-only. pub state: &'a TextInputState, pub placeholder: String, pub focused: bool, /// Envuelve los eventos del campo en el `Msg` de la app. Un solo mensaje /// para todos los gestos (tecla, IME, click, arrastre): es lo que hace que /// cablear un campo completo no cueste diez variantes de `Msg`. pub wrap: Arc Msg + Send + Sync>, } /// Spec completo del menú. pub struct HelpMenuSpec<'a, Msg: Clone + 'static> { pub title: String, /// Las entradas **ya filtradas** (ver [`aplicar_filtro`]). pub entries: Vec>, /// Cuántas quedaron afuera del filtro. Se cuentan en el pie; no se /// esconden. pub hidden: usize, /// Índice resaltado dentro de `entries` (la navegación con flechas la /// lleva el `update` de la app, que es quien sabe qué tecla llegó). pub selected: Option, pub search: Option>, /// Qué decir cuando el filtro no dejó nada. Lo trae el caller porque es /// texto que lee una persona y va en los tres idiomas. pub empty_text: String, /// Cómo redactar el pie de «hay N más». `None` = sólo el número (`+4`), /// que no necesita traducción. pub overflow_fmt: Option String + Send + Sync>>, pub viewport: (f32, f32), /// Click en el scrim (fuera del panel) o `Esc`. pub on_dismiss: Msg, /// Click **adentro** del panel pero fuera de una fila. `None` = cierra, /// como el context-menu. Una app con buscador debería mandar acá el mensaje /// que **enfoca el campo**: si no, errarle a una fila por diez píxeles /// cierra el menú y se lleva lo que la persona venía tipeando. pub on_panel_click: Option, pub palette: HelpMenuPalette, } const PANEL_W: f32 = 520.0; const PAD: f32 = 20.0; const TITLE_H: f32 = 40.0; const SEARCH_H: f32 = 38.0; const SEARCH_GAP: f32 = 12.0; /// Fila con rótulo y descripción. const ROW_H: f32 = 46.0; /// Fila apretada: sólo el rótulo. Se usa **antes** de recortar — perder la /// descripción es barato, perder una entrada entera no. const ROW_H_COMPACTA: f32 = 30.0; const ICON_W: f32 = 30.0; /// Alto de la caja del glifo. Una línea: lo que se centra es esa caja. const ICON_LINEA: f32 = 18.0; const KEYS_W: f32 = 110.0; const LABEL_FONT: f32 = 13.5; const HINT_FONT: f32 = 11.5; const TITLE_FONT: f32 = 16.0; /// Cuántas filas entran y de qué alto — la aritmética del recorte, aparte de la /// vista para poder afirmarla en un test sin pintar nada. /// /// Devuelve `(alto_de_fila, compacta, cuántas se muestran, cuántas van al pie)`. pub fn reparto( filas: usize, hidden: usize, con_buscador: bool, viewport_h: f32, ) -> (f32, bool, usize, usize) { let cabecera = TITLE_H + if con_buscador { SEARCH_H + SEARCH_GAP } else { 0.0 }; let disponible = (viewport_h - 32.0 - PAD * 2.0 - cabecera).max(ROW_H_COMPACTA * 2.0); let necesarias = filas.max(1) + usize::from(hidden > 0); // Primero se intenta con la fila alta; recién si no entra se aprieta. let (row_h, compacta) = if necesarias as f32 * ROW_H <= disponible { (ROW_H, false) } else { (ROW_H_COMPACTA, true) }; let caben = ((disponible / row_h).floor() as usize).max(2); if necesarias <= caben { return (row_h, compacta, filas, hidden); } // No entran: se reserva la última fila para el conteo de lo que no se ve. let mostradas = caben - 1; (row_h, compacta, mostradas, filas - mostradas + hidden) } pub fn help_menu_view(spec: HelpMenuSpec<'_, Msg>) -> View { let HelpMenuSpec { title, entries, hidden, selected, search, empty_text, overflow_fmt, viewport, on_dismiss, on_panel_click, palette, } = spec; let con_buscador = search.is_some(); let (row_h, compacta, mostradas, al_pie) = reparto(entries.len(), hidden, con_buscador, viewport.1); let mut hijos: Vec> = Vec::with_capacity(mostradas + 3); hijos.push( View::new(Style { size: Size { width: percent(1.0_f32), height: length(TITLE_H), }, align_items: Some(AlignItems::CENTER), flex_shrink: 0.0, ..Default::default() }) .text_aligned(title.clone(), TITLE_FONT, palette.fg_title, Alignment::Start) .role(Role::Heading), ); if let Some(s) = search { let wrap = s.wrap.clone(); let campo = text_input_view_full( s.state, &s.placeholder, s.focused, &palette.input, move |ev| (wrap)(ev), ); hijos.push( View::new(Style { size: Size { width: percent(1.0_f32), height: length(SEARCH_H), }, margin: Rect { left: length(0.0_f32), right: length(0.0_f32), top: length(0.0_f32), bottom: length(SEARCH_GAP), }, flex_shrink: 0.0, ..Default::default() }) .children(vec![campo]), ); } if entries.is_empty() { hijos.push( View::new(Style { size: Size { width: percent(1.0_f32), height: length(row_h), }, align_items: Some(AlignItems::CENTER), flex_shrink: 0.0, ..Default::default() }) .text_aligned( empty_text.clone(), LABEL_FONT, palette.fg_hint, Alignment::Start, ) .role(Role::Label) .aria_label(empty_text), ); } else { let mut lista: Vec> = Vec::with_capacity(mostradas); for (i, e) in entries.iter().take(mostradas).enumerate() { lista.push(fila_view(e, selected == Some(i), row_h, compacta, &palette)); } hijos.push( View::new(Style { flex_direction: FlexDirection::Column, size: Size { width: percent(1.0_f32), height: auto(), }, flex_grow: 1.0, ..Default::default() }) .role(Role::Menu) .children(lista), ); } if al_pie > 0 { let texto = match &overflow_fmt { Some(f) => f(al_pie), None => { let mut s = String::from("+"); s.push_str(&numero(al_pie)); s } }; hijos.push( View::new(Style { size: Size { width: percent(1.0_f32), height: length(row_h), }, align_items: Some(AlignItems::CENTER), flex_shrink: 0.0, ..Default::default() }) .text_aligned(texto.clone(), HINT_FONT, palette.fg_hint, Alignment::Start) .role(Role::Label) .aria_label(texto), ); } let alto_contenido = TITLE_H + if con_buscador { SEARCH_H + SEARCH_GAP } else { 0.0 } + (mostradas.max(1) + usize::from(al_pie > 0)) as f32 * row_h; let panel_h = (alto_contenido + PAD * 2.0).min(viewport.1 - 32.0); let panel_w = PANEL_W.min(viewport.0 - 32.0); let x = ((viewport.0 - panel_w) * 0.5).max(0.0); let y = ((viewport.1 - panel_h) * 0.5).max(0.0); let mut panel = View::new(Style { position: Position::Absolute, inset: Rect { left: length(x), top: length(y), right: auto(), bottom: auto(), }, size: Size { width: length(panel_w), height: length(panel_h), }, flex_direction: FlexDirection::Column, padding: Rect { left: length(PAD), right: length(PAD), top: length(PAD), bottom: length(PAD), }, ..Default::default() }) .paint_with(panel_signature_painter(palette.panel)) .radius(palette.panel.radius) .clip(true) // Modal sobre scrim: como `Dialog` el lector lo anuncia al abrirse y confina // la navegación adentro. .role(Role::Dialog) .aria_label(title) .children(hijos); if let Some(m) = on_panel_click { panel = panel.on_click(m); } View::new(Style { size: Size { width: percent(1.0_f32), height: percent(1.0_f32), }, ..Default::default() }) .fill(palette.scrim) .on_click(on_dismiss) .children(vec![panel]) } /// El número, sin `format!` (que arrastra el maquinario de formato al kernel /// por un entero de dos dígitos). fn numero(mut n: usize) -> String { if n == 0 { return String::from("0"); } let mut d = Vec::new(); while n > 0 { d.push(b'0' + (n % 10) as u8); n /= 10; } d.reverse(); d.into_iter().map(|b| b as char).collect() } fn fila_view( e: &HelpEntry, seleccionada: bool, row_h: f32, compacta: bool, palette: &HelpMenuPalette, ) -> View { let fg = if e.enabled { palette.fg_label } else { palette.fg_disabled }; let mut celdas: Vec> = Vec::with_capacity(3); if let Some(g) = &e.icon { // **El glifo va centrado a mano, en un hijo.** `align_items` centra a // los HIJOS, y el texto propio de un nodo no es un hijo: se pinta desde // arriba. Por eso los iconos salían pegados al rótulo en vez de al // medio de la fila. La columna con `justify_content: CENTER` y el glifo // adentro sí lo centra, y sigue funcionando cuando la fila cambia de // alto (compacta / con descripción). celdas.push( View::new(Style { flex_direction: FlexDirection::Column, justify_content: Some(JustifyContent::CENTER), size: Size { width: length(ICON_W), height: percent(1.0_f32), }, flex_shrink: 0.0, ..Default::default() }) .children(vec![View::new(Style { size: Size { width: percent(1.0_f32), height: length(ICON_LINEA), }, ..Default::default() }) .text_aligned(g.clone(), LABEL_FONT + 1.0, fg, Alignment::Start)]), ); } let mut centro: Vec> = Vec::with_capacity(2); centro.push( View::new(Style { size: Size { width: percent(1.0_f32), height: length(if compacta { row_h } else { 18.0 }), }, align_items: Some(AlignItems::CENTER), ..Default::default() }) .text_aligned(e.label.clone(), LABEL_FONT, fg, Alignment::Start), ); if !compacta && !e.hint.is_empty() { centro.push( View::new(Style { size: Size { width: percent(1.0_f32), height: length(15.0_f32), }, align_items: Some(AlignItems::CENTER), ..Default::default() }) .text_aligned( e.hint.clone(), HINT_FONT, palette.fg_hint, Alignment::Start, ), ); } celdas.push( View::new(Style { flex_direction: FlexDirection::Column, size: Size { width: auto(), height: auto(), }, flex_grow: 1.0, justify_content: Some(JustifyContent::CENTER), ..Default::default() }) .children(centro), ); if let Some(k) = &e.keys { celdas.push( View::new(Style { size: Size { width: length(KEYS_W), height: length(20.0_f32), }, align_items: Some(AlignItems::CENTER), justify_content: Some(JustifyContent::FLEX_END), padding: Rect { left: length(8.0_f32), right: length(8.0_f32), top: length(0.0_f32), bottom: length(0.0_f32), }, flex_shrink: 0.0, ..Default::default() }) .fill(palette.bg_key) .radius(radius::XS) .text_aligned(k.clone(), HINT_FONT, palette.fg_key, Alignment::End), ); } let mut fila = View::new(Style { flex_direction: FlexDirection::Row, size: Size { width: percent(1.0_f32), height: length(row_h), }, align_items: Some(AlignItems::CENTER), padding: Rect { left: length(8.0_f32), right: length(8.0_f32), top: length(0.0_f32), bottom: length(0.0_f32), }, gap: Size { width: length(8.0_f32), height: length(0.0_f32), }, flex_shrink: 0.0, ..Default::default() }) .role(Role::MenuItem) .aria_label(e.label.clone()) .children(celdas); if seleccionada { fila = fila.fill(palette.bg_selected).radius(radius::SM); } // Sin `on_click` cuando está apagada: una fila en gris que igual despacha // su mensaje es una mentira que se descubre clickeándola. Y sin hover ni // salpicadura, por lo mismo: responder al mouse es prometer que hace algo. if e.enabled { fila = fila .radius(radius::SM) .hover_fill(palette.bg_hover) // La salpicadura del click sale del punto tocado y se recorta al // contorno. La `key` tiene que ser **estable entre rebuilds**, y la // vista se rearma en cada cuadro: el índice de la fila no sirve // porque el filtro la corre de lugar. El id del tema sí — es lo // único que identifica a la entrada y no cambia con el orden. .ripple(clave(&e.topic), palette.ripple) .on_click(e.on_activate.clone()); } fila } /// Una `key` estable para la salpicadura, derivada del id del tema. /// /// Es un FNV-1a de tres líneas y no un `DefaultHasher` porque este crate es /// `no_std`: `std::collections::hash_map` no existe de este lado. fn clave(t: &HelpTopic) -> u64 { let mut h: u64 = 0xcbf2_9ce4_8422_2325; for b in t.id().as_bytes() { h ^= *b as u64; h = h.wrapping_mul(0x0000_0100_0000_01b3); } h } // ===================================================================== // Certificación — texto, no pantallazos (regla 8) // ===================================================================== #[cfg(test)] mod tests { use super::*; #[derive(Debug, Clone, PartialEq)] enum M { Ir(u8), } fn e(topic: HelpTopic, label: &str) -> HelpEntry { HelpEntry::new(topic, label, M::Ir(0)) } fn seis() -> Vec> { vec![ e(HelpTopic::Shortcuts, "Atajos"), e(HelpTopic::Manual, "Manual").hint("Cómo se usa esta app"), e(HelpTopic::Tour, "Recorrido").hint("Te lo muestro en pantalla"), e(HelpTopic::Ask, "Preguntarle a la IA"), e(HelpTopic::Report, "Algo anda mal").keywords(["bug", "diagnostico", "reporte"]), e(HelpTopic::About, "Acerca de").keywords(["version", "licencia"]), ] } /// El vocabulario no es nuevo: es el que `app-bus` ya declara en /// `AppMenu::standard()`. Si esto cambia, una app que despacha su menú /// global deja de reconocer su propia entrada de ayuda. #[test] fn los_ids_son_los_del_menu_global() { assert_eq!(HelpTopic::Shortcuts.id(), "help.shortcuts"); assert_eq!(HelpTopic::About.id(), "help.about"); assert_eq!(HelpTopic::Index("man".into()).id(), "help.index.man"); } #[test] fn consulta_vacia_deja_todo_en_el_orden_declarado() { let (v, fuera) = aplicar_filtro(&seis(), ""); assert_eq!(fuera, 0); assert_eq!(v.len(), 6); assert_eq!(v[0].label, "Atajos"); assert_eq!(v[5].label, "Acerca de"); } #[test] fn el_prefijo_gana_a_lo_que_solo_contiene() { let entradas = vec![ e(HelpTopic::Index("a".into()), "Ver el manual del sistema"), e(HelpTopic::Manual, "Manual"), ]; let (v, _) = aplicar_filtro(&entradas, "manual"); assert_eq!(v[0].label, "Manual"); } /// Quien tipea sin acentos no se puede quedar afuera de su propia ayuda. #[test] fn sin_acentos_encuentra_lo_acentuado() { let entradas = vec![e(HelpTopic::Tour, "Señalética")]; let (v, fuera) = aplicar_filtro(&entradas, "senaletica"); assert_eq!(fuera, 0); assert_eq!(v.len(), 1); } /// El caso que justifica que existan las palabras clave: nadie busca /// «Algo anda mal» — busca «bug». #[test] fn la_palabra_clave_encuentra_lo_que_el_rotulo_no_dice() { let (v, fuera) = aplicar_filtro(&seis(), "bug"); assert_eq!(v.len(), 1); assert_eq!(v[0].label, "Algo anda mal"); assert_eq!(fuera, 5); } #[test] fn la_subsecuencia_perdona_no_saber_la_palabra_entera() { let (v, _) = aplicar_filtro(&seis(), "atjs"); assert_eq!(v[0].label, "Atajos"); } /// Sin resultados, el descarte se **cuenta**: el panel puede decir que hay /// seis entradas que no matchean en vez de fingir que la app no tiene /// ayuda. #[test] fn lo_que_no_matchea_se_cuenta() { let (v, fuera) = aplicar_filtro(&seis(), "zzz"); assert!(v.is_empty()); assert_eq!(fuera, 6); } #[test] fn con_espacio_entra_la_fila_alta_y_nada_va_al_pie() { let (h, compacta, mostradas, pie) = reparto(6, 0, true, 800.0); assert_eq!(h, ROW_H); assert!(!compacta); assert_eq!(mostradas, 6); assert_eq!(pie, 0); } /// Primero se pierde la descripción; recién después se recorta. Perder un /// renglón de ayuda es barato, perder una entrada entera no. #[test] fn se_aprieta_antes_de_recortar() { let (h, compacta, mostradas, pie) = reparto(10, 0, true, 480.0); assert_eq!(h, ROW_H_COMPACTA); assert!(compacta); assert_eq!(mostradas, 10); assert_eq!(pie, 0); } /// Y cuando ni apretado entra, lo que no se ve **se cuenta**. Una lista /// cortada en silencio se lee como «no hay más». #[test] fn lo_que_no_entra_va_al_pie_y_no_desaparece() { let (_, _, mostradas, pie) = reparto(20, 3, true, 300.0); assert!(mostradas < 20); assert_eq!(mostradas + pie, 23, "no se pierde ninguna por el camino"); } #[test] fn numero_sin_format() { assert_eq!(numero(0), "0"); assert_eq!(numero(7), "7"); assert_eq!(numero(142), "142"); } /// Humo: armar la vista no paniquea ni con el menú vacío ni con el lleno. #[test] fn la_vista_se_arma() { let (entries, hidden) = aplicar_filtro(&seis(), ""); let spec = HelpMenuSpec { title: "Ayuda".into(), entries, hidden, selected: Some(0), search: None, empty_text: "Nada con ese nombre".into(), overflow_fmt: None, viewport: (1280.0, 800.0), on_dismiss: M::Ir(9), on_panel_click: None, palette: HelpMenuPalette::from_theme(&Theme::dark()), }; let _ = help_menu_view(spec); let spec = HelpMenuSpec { title: "Ayuda".into(), entries: Vec::new(), hidden: 6, selected: None, search: None, empty_text: "Nada con ese nombre".into(), overflow_fmt: None, viewport: (320.0, 240.0), on_dismiss: M::Ir(9), on_panel_click: None, palette: HelpMenuPalette::from_theme(&Theme::light()), }; let _ = help_menu_view(spec); } }