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");
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
}
}
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())
}
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 }
}
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);
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);
}
}