//! `llimphi-widget-context-menu` — menú contextual con look tawasuyu. //! //! Distintivo y minimalista: //! //! ```text //! ┌─────────────────────────┐ ← panel redondeado + borde hairline + sombra //! │ B5 │ ← header (uppercase tiny) //! │ ✂ Cortar Ctrl+X │ //! │ ⧉ Copiar Ctrl+C │ ← gutter (ícono o ✓) · fila-píldora //! │ ⎘ Pegar Ctrl+V │ //! │ ─────────────────────── │ //! │ ✓ Ajustar texto │ ← item chequeable //! │ ◐ Tema ▸ │ ← submenú (flyout, flipea si no cabe) //! └─────────────────────────┘ //! ``` //! //! Cada fila es una píldora: indicador accent (firma, sólo en la activa) · //! gutter (ícono vectorial o ✓ de check) · label (centrado vertical) · //! atajo o chevron de submenú. El panel se eleva con esquinas redondeadas, //! borde hairline y una sombra suave (look "webpage" moderno). //! //! **Teclado (helpers puros que el host cablea):** [`step_active`] (↑/↓, //! saltea sep/disabled), [`edge_active`] (Home/End), [`type_ahead`] //! (mnemónico por primera letra). El submenú se navega llamando //! [`step_active`] sobre `parent.children` (ver `examples/menu_demo.rs`). //! //! Se monta como `View` que se devuelve desde //! [`llimphi_compositor::App::view_overlay`]. Internamente arma: //! 1. Un **scrim** full-screen con `on_click = on_dismiss` que cierra //! el menú al click-fuera. //! 2. Un **panel** absoluto (clampeado al viewport). //! 3. Si hay un submenú abierto ([`ContextMenuSpec::open_sub`]), un //! segundo panel-flyout a la derecha del item padre. //! //! Animación: [`ContextMenuSpec::appear`] (0..1) controla un fade + un //! leve desplazamiento vertical de entrada. La app que quiera animarlo //! guarda un `Tween` y lo va subiendo; pasar `1.0` lo muestra fijo. #![forbid(unsafe_code)] // Sin `std`: este widget no usa nada del sistema —sólo `alloc` para // `String`/`Vec`—, y declararlo es lo que le permite cruzar a una jaula // WASM o al kernel. Antes enlazaba `std` por omisión (W4.6). #![no_std] extern crate alloc; use alloc::string::String; use alloc::vec::Vec; use alloc::vec; use alloc::string::ToString; use alloc::sync::Arc; use llimphi_compositor::llimphi_layout::taffy::{ prelude::{auto, length, percent, FlexDirection, Position, Size, Style}, AlignItems, JustifyContent, Rect, }; use llimphi_compositor::peniko::Color; use llimphi_compositor::llimphi_text::Alignment; use llimphi_compositor::View; use llimphi_widget_panel::{panel_signature_painter, PanelStyle}; /// Paleta del menú — estilo "webpage" elegante derivado del theme: /// panel redondeado con borde hairline, filas como píldoras con hover /// suave (`bg_hover`) y resaltado de teclado (`bg_active`, más un /// indicador accent a la izquierda). Defaults dark; override por la app. #[derive(Debug, Clone, Copy)] pub struct ContextMenuPalette { pub bg_panel: Color, /// Fila bajo el cursor (hover) — tinte suave. pub bg_hover: Color, /// Fila activa por teclado (flechas) — algo más marcado que el hover. pub bg_active: Color, pub fg_text: Color, /// Texto de la fila activa/hover (legible sobre el tinte suave). pub fg_active: Color, pub fg_shortcut: Color, pub fg_disabled: Color, pub fg_destructive: Color, pub fg_header: Color, /// Ícono en gutter (estado normal) — algo más apagado que el texto. pub fg_icon: Color, pub accent: Color, pub border: Color, pub separator: Color, pub scrim: Color, /// Radio de las esquinas del panel. pub radius: f64, pub panel: PanelStyle, } impl ContextMenuPalette { pub fn from_theme(t: &llimphi_theme::Theme) -> Self { // El panel se eleva sobre el fondo: usa `bg_panel` (no `bg_app`) // con su gradiente sutil + esquinas redondeadas. let mut panel = PanelStyle::neutral(t); panel.bg_base = t.bg_panel; panel.radius = PANEL_RADIUS as f64; Self { bg_panel: t.bg_panel, bg_hover: t.bg_row_hover, bg_active: t.bg_selected, fg_text: t.fg_text, fg_active: t.fg_text, fg_shortcut: t.fg_muted, fg_disabled: t.fg_muted, fg_destructive: t.fg_destructive, fg_header: t.fg_muted, fg_icon: t.fg_muted, accent: t.accent, border: t.border, separator: t.border, scrim: Color::from_rgba8(0, 0, 0, 64), radius: PANEL_RADIUS as f64, panel, } } } /// Un item del menú. `separator = true` ignora el resto y pinta una /// línea. `children` no vacío → es un submenú (muestra chevron ▸ y, si /// está abierto, despliega un flyout). `icon` es un glifo opcional que /// se pinta en el gutter izquierdo. #[derive(Debug, Clone)] pub struct ContextMenuItem { pub label: String, pub shortcut: Option, pub icon: Option, pub enabled: bool, pub separator: bool, pub destructive: bool, /// Estado chequeable (Material `CheckedPopupMenuItem` / check nativo). /// `None` = no chequeable (usa `icon`). `Some(true/false)` = chequeable: /// `true` pinta ✓ en el gutter (accent), `false` deja el gutter vacío /// pero reservado para que el label no salte al alternar. pub checked: Option, /// Items del submenú. Vacío = acción simple. pub children: Vec, } impl ContextMenuItem { pub fn action(label: impl Into) -> Self { Self { label: label.into(), shortcut: None, icon: None, enabled: true, separator: false, destructive: false, checked: None, children: Vec::new(), } } pub fn with_shortcut(mut self, shortcut: impl Into) -> Self { self.shortcut = Some(shortcut.into()); self } /// Glifo del gutter izquierdo (unicode; no acopla a `llimphi-icons`). pub fn icon(mut self, glyph: impl Into) -> Self { self.icon = Some(glyph.into()); self } pub fn disabled(mut self) -> Self { self.enabled = false; self } pub fn destructive(mut self) -> Self { self.destructive = true; self } /// Marca el item como chequeable con el estado dado (✓ cuando `on`). /// El gutter muestra el check en vez del ícono. pub fn checked(mut self, on: bool) -> Self { self.checked = Some(on); self } pub fn is_checked(&self) -> bool { self.checked == Some(true) } /// Convierte el item en submenú con estos hijos. pub fn submenu(mut self, children: Vec) -> Self { self.children = children; self } pub fn has_submenu(&self) -> bool { !self.children.is_empty() } pub fn separator() -> Self { Self { label: String::new(), shortcut: None, icon: None, enabled: false, separator: true, destructive: false, checked: None, children: Vec::new(), } } } /// Especificación del menú. Mantiene los 8 campos clásicos para no /// romper los call-sites por literal; las capacidades nuevas (submenús, /// animación, hover) viajan aparte en [`ContextMenuExtras`] vía /// [`context_menu_view_ex`]. pub struct ContextMenuSpec { pub anchor: (f32, f32), pub viewport: (f32, f32), pub header: Option, pub items: Vec, /// Índice resaltado por keyboard. `usize::MAX` = ninguno. pub active: usize, /// Click en un item de nivel raíz (índice). pub on_pick: Arc Msg + Send + Sync>, /// Msg al click-fuera (scrim) o Esc. pub on_dismiss: Msg, pub palette: ContextMenuPalette, } /// Capacidades extra opcionales para [`context_menu_view_ex`]: submenús /// (flyout), animación de aparición y hover. Su `Default` reproduce el /// menú clásico (sin animación ni submenús). pub struct ContextMenuExtras { /// Índice del item-submenú desplegado (flyout). La app lo guarda y lo /// actualiza vía `on_hover`. pub open_sub: Option, /// Progreso de aparición 0..1 (fade + leve slide). `1.0` = fijo. pub appear: f32, /// Click en un item de submenú: `(parent_idx, child_idx)`. pub on_pick_sub: Option Msg + Send + Sync>>, /// Hover sobre un item raíz: `Some(idx)` si es submenú (abrir flyout), /// `None` si es item normal (cerrar). La app guarda el resultado en /// `open_sub`. pub on_hover: Option) -> Msg + Send + Sync>>, } impl Default for ContextMenuExtras { fn default() -> Self { Self { open_sub: None, appear: 1.0, on_pick_sub: None, on_hover: None, } } } const PANEL_W: f32 = 268.0; /// Altura de cada item (no-separator). const ITEM_H: f32 = 32.0; const SEP_H: f32 = 9.0; /// Dónde arranca la columna del **rótulo**, medido desde el borde interno del /// panel: el padding de la fila + el indicador + el gutter del ícono + el gap. /// Es lo que sangra los separadores para que se alineen con el texto. const SANGRIA_TEXTO: f32 = ITEM_PAD_LEFT + INDICATOR_W + 2.0 + ICON_W; const HEADER_H: f32 = 26.0; /// Gutter del ícono a la izquierda del label. const ICON_W: f32 = 24.0; const ITEM_PAD_LEFT: f32 = 10.0; const ITEM_PAD_RIGHT: f32 = 12.0; /// Radio de las esquinas del panel (estilo webpage). const PANEL_RADIUS: f32 = 10.0; /// Radio de la píldora de hover/activo de cada fila. const ITEM_RADIUS: f32 = 6.0; /// Padding interno del panel (entre el borde y la columna de píldoras). const PANEL_PAD: f32 = 6.0; /// Ancho del indicador accent vertical de la fila activa. const INDICATOR_W: f32 = 3.0; /// Desplazamiento vertical de entrada (px) cuando `appear` = 0. const APPEAR_SLIDE: f32 = 8.0; /// Compone el menú clásico (sin submenús ni animación) como `View` /// para `App::view_overlay`. Íconos, centrado vertical y separadores ya /// vienen incluidos. pub fn context_menu_view(spec: ContextMenuSpec) -> View { context_menu_view_ex(spec, ContextMenuExtras::default()) } /// Como [`context_menu_view`] pero con [`ContextMenuExtras`]: submenús /// (flyout en hover), animación de aparición y hover. pub fn context_menu_view_ex( spec: ContextMenuSpec, extras: ContextMenuExtras, ) -> View { let ContextMenuSpec { anchor, viewport, header, items, active, on_pick, on_dismiss, palette, } = spec; let ContextMenuExtras { open_sub, appear, on_pick_sub, on_hover, } = extras; let appear = appear.clamp(0.0, 1.0); let slide = (1.0 - appear) * APPEAR_SLIDE; let (panel, panel_x, panel_y) = panel_view( anchor, viewport, &header, &items, active, slide, &on_pick, on_hover.as_ref(), &palette, ); let mut layers: Vec> = vec![panel]; // Flyout del submenú abierto (sólo si la app provee `on_pick_sub`). if let (Some(pidx), Some(on_pick_sub)) = (open_sub, on_pick_sub.as_ref()) { if let Some(parent) = items.get(pidx).filter(|it| it.has_submenu()) { let sub_anchor = submenu_anchor(panel_x, panel_y, viewport, &header, &items, pidx); let flyout = submenu_view( sub_anchor, viewport, pidx, &parent.children, slide, on_pick_sub, &palette, ); layers.push(flyout); } } // Scrim full-screen: cualquier click "fuera" dismissa. View::new(Style { size: Size { width: percent(1.0_f32), height: percent(1.0_f32), }, ..Default::default() }) .fill(palette.scrim) .alpha(appear) // El scrim: el click en el vacío cierra el menú. Es la pantalla entera y era // el único nodo mudo que quedaba en shuma y en puriy — uno por menú abierto, // y lo abren la barra de menús, los contextuales y los desplegables. .role(llimphi_compositor::Role::Button) .aria_label("Cerrar el menú") .on_click(on_dismiss) .children(layers) } /// Arma el panel raíz y devuelve `(view, x, y)` ya clampeados. #[allow(clippy::too_many_arguments)] fn panel_view( anchor: (f32, f32), viewport: (f32, f32), header: &Option, items: &[ContextMenuItem], active: usize, slide: f32, on_pick: &Arc Msg + Send + Sync>, on_hover: Option<&Arc) -> Msg + Send + Sync>>, palette: &ContextMenuPalette, ) -> (View, f32, f32) { let header_h = if header.is_some() { HEADER_H } else { 0.0 }; let items_h: f32 = items .iter() .map(|it| if it.separator { SEP_H } else { ITEM_H }) .sum(); // borde (1+1) + padding interno (PANEL_PAD ×2) + header + items. let panel_h = 2.0 + 2.0 * PANEL_PAD + header_h + items_h; let margin = 4.0; // **Y nunca más alto que la pantalla.** Sin este tope, un menú con muchas // entradas se pintaba TAL CUAL: 843 px de panel sobre un escritorio de 800, // clampeado a y=4 y sobrando por abajo. Las entradas de la cola no eran // alcanzables de ninguna manera —estaban fuera de la pantalla— y encima el // panel se comía el borde. Con el tope, el panel termina donde termina la // pantalla y lo que no entra queda recortado por el `clip` del contenedor. let panel_h = panel_h.min(viewport.1 - 2.0 * margin); let x = anchor .0 .min((viewport.0 - PANEL_W - margin).max(margin)) .max(margin); let y = anchor .1 .min((viewport.1 - panel_h - margin).max(margin)) .max(margin); let mut children: Vec> = Vec::with_capacity(items.len() + 1); if let Some(text) = header { children.push(header_view(text.clone(), palette)); } for (i, item) in items.iter().enumerate() { children.push(item_view( i, None, item, i == active, on_pick, on_hover, palette, )); } let panel = panel_container( x, y_con_slide(y, slide, panel_h, viewport.1, margin), panel_h, children, palette, ); (panel, x, y) } /// Flyout del submenú: mismo look, posicionado a la derecha del padre. #[allow(clippy::too_many_arguments)] fn submenu_view( anchor: (f32, f32), viewport: (f32, f32), parent_idx: usize, children_items: &[ContextMenuItem], slide: f32, on_pick_sub: &Arc Msg + Send + Sync>, palette: &ContextMenuPalette, ) -> View { let panel_h: f32 = children_items .iter() .map(|it| if it.separator { SEP_H } else { ITEM_H }) .sum::() + 2.0 + 2.0 * PANEL_PAD; let margin = 4.0; // Mismo tope que el panel raíz: un submenú de veinte «Abrir con» medía más // que el escritorio entero. let panel_h = panel_h.min(viewport.1 - 2.0 * margin); let x = anchor .0 .min((viewport.0 - PANEL_W - margin).max(margin)) .max(margin); let y = anchor .1 .min((viewport.1 - panel_h - margin).max(margin)) .max(margin); let mut children: Vec> = Vec::with_capacity(children_items.len()); for (j, item) in children_items.iter().enumerate() { children.push(item_view( j, Some((parent_idx, on_pick_sub.clone())), item, false, // on_pick raíz no se usa cuando hay parent; pasamos un dummy. &dummy_pick(), None, palette, )); } panel_container( x, y_con_slide(y, slide, panel_h, viewport.1, margin), panel_h, children, palette, ) } /// El contenedor visual: panel redondeado con borde hairline (un nodo /// exterior del color del borde + uno interior con el gradiente del /// PanelStyle) y padding interno para que las píldoras de cada fila /// queden inset — el look de menú de webpage. fn panel_container( x: f32, y: f32, panel_h: f32, children: Vec>, palette: &ContextMenuPalette, ) -> View { 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), }, padding: Rect { left: length(1.0_f32), right: length(1.0_f32), top: length(1.0_f32), bottom: length(1.0_f32), }, ..Default::default() }) .fill(palette.border) .radius(palette.radius as f64) // Elevación: el menú flota sobre el contenido. Sombra suave hacia abajo // (misma técnica del `llimphi-button`, más difusa por ser overlay). El // fill opaco del borde tapa el solape, así sólo asoma el halo. .shadow(llimphi_compositor::Shadow { color: Color::from_rgba8(0, 0, 0, 56), blur: 18.0, dx: 0.0, dy: 7.0, spread: -2.0, }) // Recorta: con el panel topado a la pantalla, lo que no entra se queda // adentro del panel en vez de pintarse encima de todo lo demás. .clip(true) .children(vec![View::new(Style { flex_direction: FlexDirection::Column, flex_grow: 1.0, size: Size { width: percent(1.0_f32), height: percent(1.0_f32), }, padding: Rect { left: length(PANEL_PAD), right: length(PANEL_PAD), top: length(PANEL_PAD), bottom: length(PANEL_PAD), }, ..Default::default() }) .radius((palette.radius - 1.0) as f64) .paint_with(panel_signature_painter(palette.panel)) .children(children)]) } /// Ancla del flyout: por defecto a la derecha del panel padre alineado al /// item; si no cabe a la derecha del viewport, **flipea** y abre a la /// izquierda (comportamiento nativo). El pequeño solape (`PANEL_PAD`) hace /// que el flyout se lea continuo con el padre. fn submenu_anchor( panel_x: f32, panel_y: f32, viewport: (f32, f32), header: &Option, items: &[ContextMenuItem], parent_idx: usize, ) -> (f32, f32) { let mut off = if header.is_some() { HEADER_H } else { 0.0 }; off += 1.0 + PANEL_PAD; // borde + padding interno del contenedor for it in items.iter().take(parent_idx) { off += if it.separator { SEP_H } else { ITEM_H }; } let margin = 4.0; let right_x = panel_x + PANEL_W - PANEL_PAD; // Si el flyout a la derecha se saldría del viewport, abrirlo a la // izquierda del panel padre. let x = if right_x + PANEL_W + margin > viewport.0 { panel_x - PANEL_W + PANEL_PAD } else { right_x }; (x, panel_y + off) } fn header_view(text: String, palette: &ContextMenuPalette) -> View { View::new(Style { size: Size { width: percent(1.0_f32), height: length(HEADER_H), }, padding: Rect { left: length(ITEM_PAD_LEFT + INDICATOR_W + ICON_W + 4.0), right: length(ITEM_PAD_RIGHT), top: length(2.0_f32), bottom: length(0.0_f32), }, align_items: Some(AlignItems::CENTER), ..Default::default() }) .text_aligned(text.to_uppercase(), 9.5, palette.fg_header, Alignment::Start) } /// Pinta una fila. Si `parent` es `Some((pidx, cb))`, es un item de /// submenú y clickea vía `cb(pidx, idx)`; si es `None`, es raíz y usa /// `on_pick(idx)` + (si corresponde) `on_hover` para abrir su flyout. #[allow(clippy::too_many_arguments)] fn item_view( idx: usize, parent: Option<(usize, Arc Msg + Send + Sync>)>, item: &ContextMenuItem, is_active: bool, on_pick: &Arc Msg + Send + Sync>, on_hover: Option<&Arc) -> Msg + Send + Sync>>, palette: &ContextMenuPalette, ) -> View { if item.separator { return separator_view(palette); } // Color del texto y del atajo según estado. let (fg, fg_dim): (Color, Color) = if !item.enabled { (palette.fg_disabled, palette.fg_disabled) } else if item.destructive { (palette.fg_destructive, palette.fg_shortcut) } else if is_active { (palette.fg_active, palette.fg_active) } else { (palette.fg_text, palette.fg_shortcut) }; // Ícono: accent cuando la fila está activa o chequeada (cue del menú), // si no apagado. let icon_fg = if !item.enabled { palette.fg_disabled } else if item.is_checked() || is_active { palette.accent } else { palette.fg_icon }; // Indicador accent vertical a la izquierda — visible sólo en la fila // activa; reserva su ancho siempre para que el texto no salte. let indicator = View::new(Style { size: Size { width: length(INDICATOR_W), height: percent(0.55_f32), }, flex_shrink: 0.0, ..Default::default() }); let indicator = if is_active && item.enabled { indicator.fill(palette.accent).radius(2.0) } else { indicator }; // Gutter — para un item chequeable pinta ✓ (o vacío si está off); si no, // el glifo declarado se resuelve a un ícono VECTORIAL (determinista en // toda máquina); si el catálogo no lo conoce, cae a texto. let glifo = match item.checked { Some(true) => "\u{2713}".to_string(), // ✓ Some(false) => String::new(), None => item.icon.clone().unwrap_or_default(), }; let icon_inner: View = match llimphi_icons::Icon::from_glyph(&glifo) { Some(icon) => View::new(Style { size: Size { width: length(15.0_f32), height: length(15.0_f32) }, flex_shrink: 0.0, ..Default::default() }) .children(vec![llimphi_icons::icon_view(icon, icon_fg, 1.6)]), None => View::new(Style { size: Size { width: auto(), height: auto() }, ..Default::default() }) .text_aligned(glifo, 13.0, icon_fg, Alignment::Center), }; let icon_cell = View::new(Style { size: Size { width: length(ICON_W), height: auto(), }, flex_shrink: 0.0, align_items: Some(AlignItems::CENTER), justify_content: Some(JustifyContent::CENTER), ..Default::default() }) .children(vec![icon_inner]); // Label — auto height (lo centra el align_items Center del row). let label = View::new(Style { size: Size { width: auto(), height: auto(), }, flex_grow: 1.0, ..Default::default() }) .text_aligned(item.label.clone(), 12.5, fg, Alignment::Start); // Cola: chevron de submenú o atajo de teclado. let trailing_text = if item.has_submenu() { Some(("\u{203A}".to_string(), fg)) // › } else { item.shortcut.clone().map(|s| (s, fg_dim)) }; let mut row_children: Vec> = vec![indicator, icon_cell, label]; if let Some((txt, color)) = trailing_text { row_children.push( View::new(Style { // Ancho **mínimo**, no fijo: con 64 px clavados, «Shift+Supr» // no entraba y se comía el rótulo de al lado. Alineado a la // derecha, la columna de atajos queda pareja igual, y el que // no entra empuja su propia caja en vez de desbordar. size: Size { width: auto(), height: auto() }, min_size: Size { width: length(58.0_f32), height: auto() }, flex_shrink: 0.0, ..Default::default() }) .text_aligned(txt, 11.0, color, Alignment::End), ); } let mut row = View::new(Style { size: Size { width: percent(1.0_f32), height: length(ITEM_H), }, flex_direction: FlexDirection::Row, padding: Rect { left: length(ITEM_PAD_LEFT), right: length(ITEM_PAD_RIGHT), top: length(0.0_f32), bottom: length(0.0_f32), }, gap: Size { // Dos píxeles entre el rótulo y su atajo los dejaba pegados. El // atajo es información secundaria: necesita aire para leerse como // secundaria y no como parte del nombre de la acción. width: length(10.0_f32), height: length(0.0_f32), }, align_items: Some(AlignItems::CENTER), ..Default::default() }) .radius(ITEM_RADIUS as f64) // Semántica del ítem: rol MenuItem + label visible. `disabled` se // refleja del `enabled` invertido. AccessKit lo expone como // navegable por TTS dentro del menú. .role(llimphi_compositor::Role::MenuItem) .aria_label(item.label.clone()) .aria_disabled(!item.enabled) .children(row_children); // Fondo: píldora suave en activo (teclado). El hover lo aporta // `hover_fill` (tinte aún más suave) para no competir con el activo. if is_active && item.enabled { row = row.fill(palette.bg_active); } if item.enabled { row = row.hover_fill(palette.bg_hover); match &parent { Some((pidx, cb)) => { let cb = cb.clone(); let pidx = *pidx; row = row.on_click_at(move |_, _, _, _| Some(cb(pidx, idx))); } None => { let on_pick = on_pick.clone(); row = row.on_click_at(move |_, _, _, _| Some(on_pick(idx))); // Hover abre/cierra el flyout según sea submenú o no. if let Some(on_hover) = on_hover { let on_hover = on_hover.clone(); let target = if item.has_submenu() { Some(idx) } else { None }; row = row.on_pointer_enter(on_hover(target)); } } } } row } fn separator_view(palette: &ContextMenuPalette) -> View { View::new(Style { size: Size { width: percent(1.0_f32), height: length(SEP_H), }, flex_direction: FlexDirection::Column, justify_content: Some(JustifyContent::CENTER), align_items: Some(AlignItems::CENTER), // **Arranca donde arranca el texto**, no en el borde del panel. Una // línea de borde a borde parte el menú en cajones y, con seis o siete // grupos, el menú se lee como una tabla y no como una lista. Sangrada // hasta la columna del rótulo, las líneas "cuelgan" del texto y los // grupos se leen sin cortar el panel. padding: Rect { left: length(SANGRIA_TEXTO), right: length(ITEM_PAD_RIGHT), top: length(0.0_f32), bottom: length(0.0_f32), }, ..Default::default() }) .children(vec![View::new(Style { size: Size { width: percent(1.0_f32), height: length(1.0_f32), }, ..Default::default() }) .fill(palette.separator)]) } /// **El desplazamiento de entrada, ya acotado a la pantalla.** /// /// El slide baja el panel unos píxeles mientras aparece. Cuando el menú se /// abre pegado al borde de abajo —que es donde el panel ya está topado contra /// la pantalla— esos píxeles lo empujan afuera: el `y` clampeado más el slide /// deja el panel sobresaliendo justo lo que dura la animación. Se ve como un /// menú que "rebota" contra el borde inferior y, en un layout medido, como un /// rect fuera del viewport. /// /// Acá se vuelve a aplicar el mismo clamp que fijó el `y`: con lugar, el slide /// entra entero; sin lugar, se recorta a lo que haya. fn y_con_slide(y: f32, slide: f32, panel_h: f32, viewport_h: f32, margin: f32) -> f32 { (y + slide) .min((viewport_h - panel_h - margin).max(margin)) .max(margin) } /// `on_pick` dummy para los items de submenú (que usan `on_pick_sub`). /// Nunca se invoca: `item_view` con `parent=Some` ignora `on_pick`. fn dummy_pick() -> Arc Msg + Send + Sync> { Arc::new(|_| unreachable!("submenu item usa on_pick_sub, no on_pick")) } /// Navegación por teclado: dado el activo + dirección (`+1`/`-1`), el /// siguiente índice válido (saltea separators y disabled). `usize::MAX` /// si no hay elegibles. pub fn step_active(items: &[ContextMenuItem], current: usize, direction: i32) -> usize { if items.is_empty() { return usize::MAX; } let n = items.len() as i32; let start = if current == usize::MAX { if direction >= 0 { -1 } else { n } } else { current as i32 }; let mut i = start; for _ in 0..n { i += direction; if i < 0 { i = n - 1; } else if i >= n { i = 0; } let item = &items[i as usize]; if !item.separator && item.enabled { return i as usize; } } usize::MAX } /// Home/End: primer (`to_end=false`) o último (`to_end=true`) item /// elegible (saltea separators y disabled). `usize::MAX` si no hay ninguno. pub fn edge_active(items: &[ContextMenuItem], to_end: bool) -> usize { let pickable = |it: &ContextMenuItem| !it.separator && it.enabled; if to_end { items.iter().rposition(pickable) } else { items.iter().position(pickable) } .unwrap_or(usize::MAX) } /// Type-ahead / mnemónico: siguiente item elegible cuyo label empieza por /// `ch` (case-insensitive), buscando desde `current + 1` y ciclando (así /// teclar la misma letra rota entre coincidencias). `usize::MAX` si ninguno. pub fn type_ahead(items: &[ContextMenuItem], current: usize, ch: char) -> usize { let n = items.len(); if n == 0 { return usize::MAX; } let needle = ch.to_ascii_lowercase(); let start = if current == usize::MAX { 0 } else { current }; for k in 1..=n { let i = (start + k) % n; let it = &items[i]; if it.separator || !it.enabled { continue; } let first = it.label.chars().next().map(|c| c.to_ascii_lowercase()); if first == Some(needle) { return i; } } usize::MAX } #[cfg(test)] mod tests { #[test] fn el_slide_de_entrada_no_empuja_el_menu_fuera_de_la_pantalla() { // Con lugar de sobra, el slide entra entero. assert_eq!(y_con_slide(100.0, 8.0, 200.0, 800.0, 4.0), 108.0); // Contra el borde de abajo —un menú abierto en la esquina, que es // donde el panel YA está topado— el slide se recorta: sin esto, el // menú sobresalía justo mientras duraba la animación. let panel_h = 792.0; let y = y_con_slide(4.0, 8.0, panel_h, 800.0, 4.0); assert!(y + panel_h <= 800.0, "y={y} + {panel_h} se sale de 800"); assert_eq!(y, 4.0); // Y nunca negativo, aunque el panel no entre. assert!(y_con_slide(4.0, 8.0, 2000.0, 800.0, 4.0) >= 0.0); } use super::*; fn it(label: &str) -> ContextMenuItem { ContextMenuItem::action(label) } #[test] fn step_active_skips_separators() { let items = vec![it("A"), ContextMenuItem::separator(), it("B"), it("C")]; assert_eq!(step_active(&items, 0, 1), 2); assert_eq!(step_active(&items, 2, -1), 0); } #[test] fn step_active_skips_disabled() { let items = vec![it("A"), it("B").disabled(), it("C")]; assert_eq!(step_active(&items, 0, 1), 2); assert_eq!(step_active(&items, 2, -1), 0); } #[test] fn step_active_wraps_around() { let items = vec![it("A"), it("B"), it("C")]; assert_eq!(step_active(&items, 2, 1), 0); assert_eq!(step_active(&items, 0, -1), 2); } #[test] fn submenu_y_icono_se_setean() { let item = it("Tema") .icon("◐") .submenu(vec![it("Oscuro"), it("Claro")]); assert!(item.has_submenu()); assert_eq!(item.children.len(), 2); assert_eq!(item.icon.as_deref(), Some("◐")); } #[test] fn edge_active_home_end_saltea_bordes() { let items = vec![ ContextMenuItem::separator(), it("A").disabled(), it("B"), it("C"), it("D").disabled(), ContextMenuItem::separator(), ]; // Home = primer elegible (B, idx 2), no el separator ni el disabled. assert_eq!(edge_active(&items, false), 2); // End = último elegible (C, idx 3), no el disabled ni el separator. assert_eq!(edge_active(&items, true), 3); } #[test] fn edge_active_sin_elegibles_es_max() { let items = vec![ContextMenuItem::separator(), it("X").disabled()]; assert_eq!(edge_active(&items, false), usize::MAX); assert_eq!(edge_active(&items, true), usize::MAX); } #[test] fn type_ahead_salta_por_primera_letra_y_cicla() { let items = vec![it("Abrir"), it("Buscar"), it("Borrar"), it("Cerrar")]; // Desde ninguno, 'b' → primer "B" (Buscar, idx 1). assert_eq!(type_ahead(&items, usize::MAX, 'b'), 1); // Estando en Buscar, otra 'b' → siguiente "B" (Borrar, idx 2). assert_eq!(type_ahead(&items, 1, 'b'), 2); // Estando en Borrar, otra 'b' → cicla de vuelta a Buscar. assert_eq!(type_ahead(&items, 2, 'b'), 1); // Case-insensitive. assert_eq!(type_ahead(&items, usize::MAX, 'C'), 3); // Sin coincidencia. assert_eq!(type_ahead(&items, usize::MAX, 'z'), usize::MAX); } #[test] fn type_ahead_saltea_disabled_y_separators() { let items = vec![ it("Alfa").disabled(), ContextMenuItem::separator(), it("Ajuste"), ]; assert_eq!(type_ahead(&items, usize::MAX, 'a'), 2); } #[test] fn checked_builder_y_gutter() { let on = it("Ajustar texto").checked(true); let off = it("Ajustar texto").checked(false); assert_eq!(on.checked, Some(true)); assert!(on.is_checked()); assert_eq!(off.checked, Some(false)); assert!(!off.is_checked()); // Un item normal no es chequeable. assert_eq!(it("X").checked, None); } #[test] fn submenu_flipea_a_la_izquierda_sin_espacio() { let items = vec![it("Tema").submenu(vec![it("Oscuro")])]; // Panel pegado al borde derecho: no cabe a la derecha → abre a la izq. let vp = (400.0, 600.0); let panel_x = vp.0 - PANEL_W - 4.0; // clampeado al borde derecho let (x_flip, _) = submenu_anchor(panel_x, 20.0, vp, &None, &items, 0); assert!( x_flip < panel_x, "sin espacio a la derecha, el flyout debe abrir a la izquierda (x_flip={x_flip}, panel_x={panel_x})" ); // Con panel a la izquierda y viewport ancho: abre a la derecha. let vp_wide = (2000.0, 600.0); let (x_right, _) = submenu_anchor(20.0, 20.0, vp_wide, &None, &items, 0); assert!(x_right > 20.0, "con espacio, abre a la derecha"); } #[test] fn extras_default_es_menu_clasico() { let extras: ContextMenuExtras = ContextMenuExtras::default(); assert_eq!(extras.appear, 1.0); assert!(extras.open_sub.is_none()); assert!(extras.on_hover.is_none()); assert!(extras.on_pick_sub.is_none()); } }