aura-gui 0.2.2

UI nativa acelerada por GPU para escritorio, Android e iOS, en un solo crate: estilo tipo Tailwind, componentes, sombras, animaciones y agentes de IA. Reposo a cero. Licencia AGPL-3.0 o comercial.
//! Menú contextual: se usa con el ratón o con el teclado (flechas o `j`/`k`, Enter o espacio, Escape).
//! El dueño guarda la fila resaltada y atiende las acciones `menu::*` mientras el menú tiene el foco.

use crate::core::{FocusHandle, px};
use crate::view::keymap::Keymap;
use crate::view::prelude::*;
use crate::widgets::icons::{IconName, icon};
use std::rc::Rc;

pub const MENU_NEXT: Action = Action("menu::SelectNext");
pub const MENU_PREVIOUS: Action = Action("menu::SelectPrevious");
pub const MENU_CONFIRM: Action = Action("menu::Confirm");
pub const MENU_CANCEL: Action = Action("menu::Cancel");

/// Atajos del menú abierto (contexto `ContextMenu`).
pub fn bind_context_menu_keys(keymap: &mut Keymap) {
    let keys = [
        ("down", MENU_NEXT),
        ("j", MENU_NEXT),
        ("ctrl-n", MENU_NEXT),
        ("up", MENU_PREVIOUS),
        ("k", MENU_PREVIOUS),
        ("ctrl-p", MENU_PREVIOUS),
        ("enter", MENU_CONFIRM),
        ("space", MENU_CONFIRM),
        ("escape", MENU_CANCEL),
    ];
    for (key, action) in keys {
        if let Err(error) = keymap.bind(key, Some(action), Some("ContextMenu")) {
            eprintln!("atajo inválido: {error}");
        }
    }
}

pub struct MenuItem {
    pub label: String,
    pub shortcut: Option<String>,
    pub action: Option<Action>,
    pub icon: Option<IconName>,
}

impl MenuItem {
    pub fn new(label: impl Into<String>, action: Action) -> Self {
        Self { label: label.into(), shortcut: None, action: Some(action), icon: None }
    }

    pub fn separator() -> Self {
        Self { label: String::new(), shortcut: None, action: None, icon: None }
    }

    pub fn shortcut(mut self, shortcut: Option<String>) -> Self {
        self.shortcut = shortcut;
        self
    }

    pub fn icon(mut self, icon: IconName) -> Self {
        self.icon = Some(icon);
        self
    }
}

/// Siguiente fila elegible desde `current` en la dirección de `delta`, saltando separadores y dando la vuelta.
/// Sin fila resaltada, bajar empieza por la primera y subir por la última.
pub fn step_highlight(items: &[MenuItem], current: Option<usize>, delta: isize) -> Option<usize> {
    let count = items.len() as isize;
    let start = current.map_or(if delta > 0 { -1 } else { count }, |index| index as isize);
    (1..=count)
        .map(|offset| (start + offset * delta.signum()).rem_euclid(count) as usize)
        .find(|&index| items[index].action.is_some())
}

/// Menú en `position`; un clic fuera o en un elemento lo cierra con `on_dismiss`.
pub struct ContextMenu {
    position: Point,
    items: Vec<MenuItem>,
    on_dismiss: Rc<dyn Fn(&mut EventContext)>,
    focus: Option<FocusHandle>,
    highlighted: Option<usize>,
}

impl ContextMenu {
    pub fn new(position: Point, items: Vec<MenuItem>, on_dismiss: impl Fn(&mut EventContext) + 'static) -> Self {
        Self { position, items, on_dismiss: Rc::new(on_dismiss), focus: None, highlighted: None }
    }

    /// Recibe el teclado con `focus` y pinta resaltada la fila `highlighted`.
    pub fn keyboard(mut self, focus: FocusHandle, highlighted: Option<usize>) -> Self {
        (self.focus, self.highlighted) = (Some(focus), highlighted);
        self
    }
}

impl IntoElement for ContextMenu {
    fn into_element(self) -> AnyElement {
        let highlighted = self.highlighted;
        let rows: Vec<Div> =
            self.items
                .into_iter()
                .enumerate()
                .map(|(index, item)| {
                    let Some(action) = item.action else {
                        return div().h(px(1.0)).my_1().bg(Token::BorderSubtle);
                    };
                    let dismiss = self.on_dismiss.clone();
                    div()
                        .flex_row()
                        .items_center()
                        .gap_2()
                        .h(px(28.0))
                        .px_2()
                        .rounded_md()
                        .text_sm()
                        .when(highlighted == Some(index), |row| row.bg(Token::Selection))
                        .hover(|style| style.bg(Token::Hover))
                        .on_click(move |_, cx| {
                            dismiss(cx);
                            cx.dispatch_action(action);
                        })
                        .child(item.icon.map_or_else(
                            || div().w(px(16.0)),
                            |name| div().child(icon(name).text_color(Token::TextMuted)),
                        ))
                        .child(div().flex_1().child(item.label))
                        .when_some(item.shortcut, |row, shortcut| {
                            row.child(div().pl_4().text_xs().text_color(Token::TextMuted).child(shortcut))
                        })
                })
                .collect();
        let dismiss = self.on_dismiss.clone();
        let panel = div()
            .occlude()
            .flex_col()
            .min_w(px(200.0))
            .p_1()
            .rounded_lg()
            .border_1()
            .border_color(Token::Border)
            .bg(Token::ElevatedSurface)
            .shadow_md()
            .children(rows);
        // El foco va en el fondo y no en el panel: lo que `anchored` pinta queda fuera del árbol de despacho del
        // dueño, y así las teclas del menú llegan a sus manejadores.
        div()
            .key_context("ContextMenu")
            .when_some(self.focus, |backdrop, focus| backdrop.track_focus(&focus))
            .absolute()
            .inset_0()
            .occlude()
            .on_mouse_down(move |_, cx| dismiss(cx))
            .child(anchored().position(self.position).child(panel))
            .into_element()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn el_teclado_salta_separadores_y_da_la_vuelta() {
        const A: Action = Action("a");
        let items = [MenuItem::new("uno", A), MenuItem::separator(), MenuItem::new("dos", A)];
        assert_eq!(step_highlight(&items, None, 1), Some(0), "bajar empieza por la primera");
        assert_eq!(step_highlight(&items, None, -1), Some(2), "subir empieza por la última");
        assert_eq!(step_highlight(&items, Some(0), 1), Some(2), "salta el separador");
        assert_eq!(step_highlight(&items, Some(2), 1), Some(0), "da la vuelta");
        assert_eq!(step_highlight(&[MenuItem::separator()], None, 1), None);
        assert_eq!(step_highlight(&[], None, 1), None);
    }
}