use crate::backend::{Backend, ResolvedMenuStyle};
use crate::event::{Point, UiEvent};
use crate::primitives::context_menu::{ContextMenu, ContextMenuHit, ContextMenuLayout};
use crate::{Key, NamedKey};
#[derive(Debug, Clone, PartialEq)]
pub enum ContextMenuOutcome {
Event(UiEvent),
Consumed,
Ignored,
}
#[derive(Debug, Default)]
pub struct ContextMenuController {
open: Option<OpenMenu>,
}
#[derive(Debug, Clone)]
struct OpenMenu {
menu: ContextMenu,
anchor: Point,
}
fn item_measure(lh: f32) -> (f32, f32, f32) {
let item_h = (lh * 1.4).round().max(lh);
let sep_h = (lh * 0.5).round().max(1.0);
let menu_width = 20.0 * lh;
(item_h, sep_h, menu_width)
}
impl ContextMenuController {
pub fn new() -> Self {
Self { open: None }
}
pub fn is_open(&self) -> bool {
self.open.is_some()
}
pub fn open(&mut self, menu: ContextMenu, anchor: Point, backend: &mut dyn Backend) {
match backend.effective_menu_style() {
ResolvedMenuStyle::Native => {
self.close(backend);
backend.show_context_menu(&menu, anchor);
}
ResolvedMenuStyle::Custom => {
let id = menu.id;
let layout = self.layout_for(&menu, anchor, backend);
backend
.modal_stack_handle()
.borrow_mut()
.push(id, layout.bounds);
self.open = Some(OpenMenu { menu, anchor });
}
}
}
pub fn close(&mut self, backend: &mut dyn Backend) {
if let Some(open) = self.open.take() {
backend.modal_stack_handle().borrow_mut().pop(&open.menu.id);
}
}
pub fn handle(&mut self, event: &UiEvent, backend: &mut dyn Backend) -> ContextMenuOutcome {
let Some(open) = self.open.clone() else {
return ContextMenuOutcome::Ignored;
};
match event {
UiEvent::KeyPressed {
key: Key::Named(NamedKey::Escape),
..
} => {
self.close(backend);
ContextMenuOutcome::Event(UiEvent::ContextMenuDismissed)
}
UiEvent::KeyPressed {
key: Key::Named(NamedKey::Down),
..
} => {
let next = open.menu.move_selection(open.menu.selected_idx, 1);
self.set_selected(next);
ContextMenuOutcome::Consumed
}
UiEvent::KeyPressed {
key: Key::Named(NamedKey::Up),
..
} => {
let next = open.menu.move_selection(open.menu.selected_idx, -1);
self.set_selected(next);
ContextMenuOutcome::Consumed
}
UiEvent::KeyPressed {
key: Key::Named(NamedKey::Enter),
..
} => {
let item = open.menu.items.get(open.menu.selected_idx);
match item.and_then(|i| i.id) {
Some(id) => {
self.close(backend);
ContextMenuOutcome::Event(UiEvent::ContextMenuItemActivated(id))
}
None => ContextMenuOutcome::Consumed,
}
}
UiEvent::MouseDown { position, .. } => {
let layout = self.layout_for(&open.menu, open.anchor, backend);
match layout.hit_test(position.x, position.y) {
ContextMenuHit::Item(id) => {
self.close(backend);
ContextMenuOutcome::Event(UiEvent::ContextMenuItemActivated(id))
}
ContextMenuHit::Inert => ContextMenuOutcome::Consumed,
ContextMenuHit::Empty => {
self.close(backend);
ContextMenuOutcome::Event(UiEvent::ContextMenuDismissed)
}
}
}
_ => ContextMenuOutcome::Ignored,
}
}
pub fn render(&self, backend: &mut dyn Backend) {
let Some(open) = &self.open else {
return;
};
let layout = self.layout_for(&open.menu, open.anchor, backend);
let _ = backend.draw_context_menu(&open.menu, &layout);
}
fn set_selected(&mut self, idx: usize) {
if let Some(open) = &mut self.open {
open.menu.selected_idx = idx;
}
}
fn layout_for(
&self,
menu: &ContextMenu,
anchor: Point,
backend: &dyn Backend,
) -> ContextMenuLayout {
let lh = backend.chrome_line_height();
let (item_h, sep_h, menu_width) = item_measure(lh);
let vp = backend.context_menu_viewport();
menu.layout(anchor.x, anchor.y, vp, menu_width, |i| {
if menu.items[i].is_separator() {
crate::primitives::context_menu::ContextMenuItemMeasure::new(sep_h)
} else {
crate::primitives::context_menu::ContextMenuItemMeasure::new(item_h)
}
})
}
}
#[cfg(all(test, feature = "tui"))]
mod tests {
use super::*;
use crate::primitives::context_menu::ContextMenuItem;
use crate::tui::TuiBackend;
use crate::types::{Modifiers, StyledText, WidgetId};
use crate::MouseButton;
fn item(id: &str, label: &str) -> ContextMenuItem {
ContextMenuItem {
id: Some(WidgetId::new(id)),
label: StyledText::plain(label),
..Default::default()
}
}
fn menu() -> ContextMenu {
ContextMenu {
id: WidgetId::new("ctx"),
items: vec![item("copy", "Copy"), item("paste", "Paste")],
selected_idx: 0,
bg: None,
placement: crate::primitives::context_menu::ContextMenuPlacement::AnchorPoint,
}
}
#[test]
fn open_paints_when_resolved_custom() {
let mut backend = TuiBackend::new();
assert_eq!(backend.effective_menu_style(), ResolvedMenuStyle::Custom);
let mut ctl = ContextMenuController::new();
ctl.open(menu(), Point::new(5.0, 5.0), &mut backend);
assert!(ctl.is_open());
assert_eq!(
backend
.modal_stack_handle()
.borrow()
.top()
.map(|e| e.id.clone()),
Some(WidgetId::new("ctx"))
);
}
#[test]
fn click_on_item_activates_and_closes() {
let mut backend = TuiBackend::new();
let mut ctl = ContextMenuController::new();
ctl.open(menu(), Point::new(0.0, 0.0), &mut backend);
let outcome = ctl.handle(
&UiEvent::MouseDown {
widget: None,
button: MouseButton::Left,
position: Point::new(1.0, 1.0),
modifiers: Modifiers::default(),
},
&mut backend,
);
assert_eq!(
outcome,
ContextMenuOutcome::Event(UiEvent::ContextMenuItemActivated(WidgetId::new("copy")))
);
assert!(!ctl.is_open());
}
#[test]
fn click_outside_dismisses() {
let mut backend = TuiBackend::new();
let mut ctl = ContextMenuController::new();
ctl.open(menu(), Point::new(10.0, 10.0), &mut backend);
let outcome = ctl.handle(
&UiEvent::MouseDown {
widget: None,
button: MouseButton::Left,
position: Point::new(0.0, 0.0),
modifiers: Modifiers::default(),
},
&mut backend,
);
assert_eq!(
outcome,
ContextMenuOutcome::Event(UiEvent::ContextMenuDismissed)
);
assert!(!ctl.is_open());
}
#[test]
fn escape_dismisses() {
let mut backend = TuiBackend::new();
let mut ctl = ContextMenuController::new();
ctl.open(menu(), Point::new(0.0, 0.0), &mut backend);
let outcome = ctl.handle(
&UiEvent::KeyPressed {
key: Key::Named(NamedKey::Escape),
modifiers: Modifiers::default(),
repeat: false,
},
&mut backend,
);
assert_eq!(
outcome,
ContextMenuOutcome::Event(UiEvent::ContextMenuDismissed)
);
assert!(!ctl.is_open());
}
#[test]
fn handle_ignores_when_nothing_open() {
let mut backend = TuiBackend::new();
let mut ctl = ContextMenuController::new();
let outcome = ctl.handle(
&UiEvent::KeyPressed {
key: Key::Named(NamedKey::Escape),
modifiers: Modifiers::default(),
repeat: false,
},
&mut backend,
);
assert_eq!(outcome, ContextMenuOutcome::Ignored);
}
#[test]
fn down_then_enter_activates_second_item() {
let mut backend = TuiBackend::new();
let mut ctl = ContextMenuController::new();
ctl.open(menu(), Point::new(0.0, 0.0), &mut backend);
let down = ctl.handle(
&UiEvent::KeyPressed {
key: Key::Named(NamedKey::Down),
modifiers: Modifiers::default(),
repeat: false,
},
&mut backend,
);
assert_eq!(down, ContextMenuOutcome::Consumed);
let outcome = ctl.handle(
&UiEvent::KeyPressed {
key: Key::Named(NamedKey::Enter),
modifiers: Modifiers::default(),
repeat: false,
},
&mut backend,
);
assert_eq!(
outcome,
ContextMenuOutcome::Event(UiEvent::ContextMenuItemActivated(WidgetId::new("paste")))
);
}
#[test]
fn set_menu_style_custom_stays_painted_even_with_native_caps() {
let mut backend = TuiBackend::new();
backend.set_menu_style(crate::backend::MenuStyle::Custom);
assert_eq!(backend.menu_style(), crate::backend::MenuStyle::Custom);
assert_eq!(backend.effective_menu_style(), ResolvedMenuStyle::Custom);
}
}