use crate::input::shortcuts::KeyboardShortcut;
use crate::input::widget_state::WidgetId;
#[derive(Clone, Debug)]
pub struct ContextMenuItem {
pub id: String,
pub label: String,
pub shortcut: Option<KeyboardShortcut>,
pub enabled: bool,
pub separator_after: bool,
pub icon: Option<String>,
}
impl ContextMenuItem {
pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
Self {
id: id.into(),
label: label.into(),
shortcut: None,
enabled: true,
separator_after: false,
icon: None,
}
}
pub fn with_shortcut(mut self, shortcut: KeyboardShortcut) -> Self {
self.shortcut = Some(shortcut);
self
}
pub fn disabled(mut self) -> Self {
self.enabled = false;
self
}
pub fn with_separator(mut self) -> Self {
self.separator_after = true;
self
}
pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
self.icon = Some(icon.into());
self
}
pub fn separator() -> Self {
Self {
id: String::new(),
label: String::new(),
shortcut: None,
enabled: false,
separator_after: true,
icon: None,
}
}
pub fn is_separator(&self) -> bool {
self.id.is_empty() && self.label.is_empty()
}
}
#[derive(Clone, Debug)]
pub struct ContextMenuRequest {
pub position: (f64, f64),
pub items: Vec<ContextMenuItem>,
pub source_widget: Option<WidgetId>,
}
impl ContextMenuRequest {
pub fn new(position: (f64, f64), items: Vec<ContextMenuItem>) -> Self {
Self {
position,
items,
source_widget: None,
}
}
pub fn with_source(
position: (f64, f64),
items: Vec<ContextMenuItem>,
source: WidgetId,
) -> Self {
Self {
position,
items,
source_widget: Some(source),
}
}
}
#[derive(Clone, Debug, Default)]
pub struct ContextMenuState {
active: Option<ContextMenuRequest>,
hovered_item: Option<usize>,
menu_rect: Option<(f64, f64, f64, f64)>,
}
impl ContextMenuState {
pub fn new() -> Self {
Self::default()
}
pub fn open(&mut self, position: (f64, f64), items: Vec<ContextMenuItem>) {
self.active = Some(ContextMenuRequest::new(position, items));
self.hovered_item = None;
self.menu_rect = None;
}
pub fn open_for_widget(
&mut self,
position: (f64, f64),
items: Vec<ContextMenuItem>,
widget: WidgetId,
) {
self.active = Some(ContextMenuRequest::with_source(position, items, widget));
self.hovered_item = None;
self.menu_rect = None;
}
pub fn close(&mut self) {
self.active = None;
self.hovered_item = None;
self.menu_rect = None;
}
pub fn is_open(&self) -> bool {
self.active.is_some()
}
pub fn get_active(&self) -> Option<&ContextMenuRequest> {
self.active.as_ref()
}
pub fn set_menu_rect(&mut self, rect: (f64, f64, f64, f64)) {
self.menu_rect = Some(rect);
}
pub fn get_menu_rect(&self) -> Option<(f64, f64, f64, f64)> {
self.menu_rect
}
pub fn set_hovered(&mut self, index: Option<usize>) {
self.hovered_item = index;
}
pub fn get_hovered(&self) -> Option<usize> {
self.hovered_item
}
pub fn handle_click(&mut self, index: usize) -> Option<String> {
if let Some(ref request) = self.active {
if let Some(item) = request.items.get(index) {
if item.enabled && !item.is_separator() {
return Some(item.id.clone());
}
}
}
None
}
pub fn item_count(&self) -> usize {
self.active
.as_ref()
.map(|req| req.items.len())
.unwrap_or(0)
}
pub fn get_item(&self, index: usize) -> Option<&ContextMenuItem> {
self.active
.as_ref()
.and_then(|req| req.items.get(index))
}
}
#[derive(Clone, Debug, Default)]
pub struct ContextMenuResult {
pub should_close: bool,
pub clicked_item: Option<String>,
pub hovered_index: Option<usize>,
}
impl ContextMenuResult {
pub fn new() -> Self {
Self::default()
}
pub fn close() -> Self {
Self {
should_close: true,
..Default::default()
}
}
pub fn clicked(item_id: String) -> Self {
Self {
should_close: true,
clicked_item: Some(item_id),
hovered_index: None,
}
}
pub fn hovered(index: usize) -> Self {
Self {
should_close: false,
clicked_item: None,
hovered_index: Some(index),
}
}
}
#[allow(clippy::type_complexity)]
pub fn handle_context_menu_input(
state: &mut ContextMenuState,
item_rects: &[(usize, (f64, f64, f64, f64))],
cursor_pos: Option<(f64, f64)>,
clicked: bool,
clicked_outside: bool,
) -> ContextMenuResult {
if clicked_outside {
return ContextMenuResult::close();
}
if let Some((cx, cy)) = cursor_pos {
let mut hovered_index = None;
for &(index, (x, y, w, h)) in item_rects {
if cx >= x && cx < x + w && cy >= y && cy < y + h {
hovered_index = Some(index);
break;
}
}
state.set_hovered(hovered_index);
if clicked {
if let Some(index) = hovered_index {
if let Some(item_id) = state.handle_click(index) {
return ContextMenuResult::clicked(item_id);
}
}
}
if let Some(index) = hovered_index {
return ContextMenuResult::hovered(index);
}
}
ContextMenuResult::new()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::input::events::KeyCode;
#[test]
fn test_menu_item_creation() {
let item = ContextMenuItem::new("copy", "Copy");
assert_eq!(item.id, "copy");
assert_eq!(item.label, "Copy");
assert!(item.enabled);
assert!(!item.separator_after);
assert!(item.shortcut.is_none());
assert!(item.icon.is_none());
}
#[test]
fn test_menu_item_builder() {
let item = ContextMenuItem::new("copy", "Copy")
.with_shortcut(KeyboardShortcut::command(KeyCode::C))
.with_icon("clipboard")
.with_separator();
assert!(item.shortcut.is_some());
assert_eq!(item.icon, Some("clipboard".to_string()));
assert!(item.separator_after);
assert!(item.enabled);
}
#[test]
fn test_menu_item_disabled() {
let item = ContextMenuItem::new("paste", "Paste").disabled();
assert!(!item.enabled);
}
#[test]
fn test_separator_item() {
let sep = ContextMenuItem::separator();
assert!(sep.is_separator());
assert!(!sep.enabled);
assert!(sep.separator_after);
assert_eq!(sep.id, "");
assert_eq!(sep.label, "");
}
#[test]
fn test_menu_state_open_close() {
let mut menu = ContextMenuState::new();
assert!(!menu.is_open());
let items = vec![
ContextMenuItem::new("copy", "Copy"),
ContextMenuItem::new("paste", "Paste"),
];
menu.open((100.0, 200.0), items);
assert!(menu.is_open());
assert_eq!(menu.item_count(), 2);
menu.close();
assert!(!menu.is_open());
assert_eq!(menu.item_count(), 0);
}
#[test]
fn test_menu_state_with_widget() {
let mut menu = ContextMenuState::new();
let widget_id = WidgetId::new("my_widget");
let items = vec![ContextMenuItem::new("action", "Action")];
menu.open_for_widget((100.0, 200.0), items, widget_id.clone());
assert!(menu.is_open());
let request = menu.get_active().unwrap();
assert_eq!(request.source_widget, Some(widget_id));
}
#[test]
fn test_hover_tracking() {
let mut menu = ContextMenuState::new();
let items = vec![
ContextMenuItem::new("item1", "Item 1"),
ContextMenuItem::new("item2", "Item 2"),
];
menu.open((100.0, 200.0), items);
assert_eq!(menu.get_hovered(), None);
menu.set_hovered(Some(0));
assert_eq!(menu.get_hovered(), Some(0));
menu.set_hovered(Some(1));
assert_eq!(menu.get_hovered(), Some(1));
menu.set_hovered(None);
assert_eq!(menu.get_hovered(), None);
}
#[test]
fn test_click_handling_enabled() {
let mut menu = ContextMenuState::new();
let items = vec![
ContextMenuItem::new("copy", "Copy"),
ContextMenuItem::new("paste", "Paste"),
];
menu.open((100.0, 200.0), items);
let result = menu.handle_click(0);
assert_eq!(result, Some("copy".to_string()));
let result = menu.handle_click(1);
assert_eq!(result, Some("paste".to_string()));
}
#[test]
fn test_click_handling_disabled() {
let mut menu = ContextMenuState::new();
let items = vec![
ContextMenuItem::new("copy", "Copy"),
ContextMenuItem::new("paste", "Paste").disabled(),
];
menu.open((100.0, 200.0), items);
let result = menu.handle_click(0);
assert_eq!(result, Some("copy".to_string()));
let result = menu.handle_click(1);
assert_eq!(result, None);
}
#[test]
fn test_click_handling_separator() {
let mut menu = ContextMenuState::new();
let items = vec![
ContextMenuItem::new("copy", "Copy"),
ContextMenuItem::separator(),
ContextMenuItem::new("paste", "Paste"),
];
menu.open((100.0, 200.0), items);
let result = menu.handle_click(1);
assert_eq!(result, None);
let result = menu.handle_click(0);
assert_eq!(result, Some("copy".to_string()));
let result = menu.handle_click(2);
assert_eq!(result, Some("paste".to_string()));
}
#[test]
fn test_click_out_of_bounds() {
let mut menu = ContextMenuState::new();
let items = vec![ContextMenuItem::new("copy", "Copy")];
menu.open((100.0, 200.0), items);
let result = menu.handle_click(5);
assert_eq!(result, None);
}
#[test]
fn test_get_item() {
let mut menu = ContextMenuState::new();
let items = vec![
ContextMenuItem::new("copy", "Copy"),
ContextMenuItem::new("paste", "Paste"),
];
menu.open((100.0, 200.0), items);
let item = menu.get_item(0).unwrap();
assert_eq!(item.id, "copy");
let item = menu.get_item(1).unwrap();
assert_eq!(item.id, "paste");
assert!(menu.get_item(2).is_none());
}
#[test]
fn test_menu_rect() {
let mut menu = ContextMenuState::new();
assert_eq!(menu.get_menu_rect(), None);
menu.set_menu_rect((100.0, 200.0, 150.0, 80.0));
assert_eq!(menu.get_menu_rect(), Some((100.0, 200.0, 150.0, 80.0)));
}
#[test]
fn test_handle_input_hover() {
let mut menu = ContextMenuState::new();
let items = vec![
ContextMenuItem::new("item1", "Item 1"),
ContextMenuItem::new("item2", "Item 2"),
];
menu.open((100.0, 200.0), items);
let item_rects = vec![
(0, (100.0, 200.0, 150.0, 20.0)),
(1, (100.0, 220.0, 150.0, 20.0)),
];
let result = handle_context_menu_input(
&mut menu,
&item_rects,
Some((125.0, 210.0)),
false,
false,
);
assert!(!result.should_close);
assert_eq!(result.hovered_index, Some(0));
assert_eq!(menu.get_hovered(), Some(0));
let result = handle_context_menu_input(
&mut menu,
&item_rects,
Some((125.0, 230.0)),
false,
false,
);
assert_eq!(result.hovered_index, Some(1));
assert_eq!(menu.get_hovered(), Some(1));
}
#[test]
fn test_handle_input_click() {
let mut menu = ContextMenuState::new();
let items = vec![
ContextMenuItem::new("copy", "Copy"),
ContextMenuItem::new("paste", "Paste"),
];
menu.open((100.0, 200.0), items);
let item_rects = vec![
(0, (100.0, 200.0, 150.0, 20.0)),
(1, (100.0, 220.0, 150.0, 20.0)),
];
let result = handle_context_menu_input(
&mut menu,
&item_rects,
Some((125.0, 210.0)),
true,
false,
);
assert!(result.should_close);
assert_eq!(result.clicked_item, Some("copy".to_string()));
}
#[test]
fn test_handle_input_click_outside() {
let mut menu = ContextMenuState::new();
let items = vec![ContextMenuItem::new("copy", "Copy")];
menu.open((100.0, 200.0), items);
let item_rects = vec![(0, (100.0, 200.0, 150.0, 20.0))];
let result = handle_context_menu_input(
&mut menu,
&item_rects,
Some((50.0, 50.0)),
true,
true,
);
assert!(result.should_close);
assert_eq!(result.clicked_item, None);
}
#[test]
fn test_handle_input_disabled_item() {
let mut menu = ContextMenuState::new();
let items = vec![
ContextMenuItem::new("copy", "Copy"),
ContextMenuItem::new("paste", "Paste").disabled(),
];
menu.open((100.0, 200.0), items);
let item_rects = vec![
(0, (100.0, 200.0, 150.0, 20.0)),
(1, (100.0, 220.0, 150.0, 20.0)),
];
let result = handle_context_menu_input(
&mut menu,
&item_rects,
Some((125.0, 230.0)),
true,
false,
);
assert!(!result.should_close);
assert_eq!(result.clicked_item, None);
}
#[test]
fn test_context_menu_result() {
let result = ContextMenuResult::new();
assert!(!result.should_close);
assert_eq!(result.clicked_item, None);
assert_eq!(result.hovered_index, None);
let result = ContextMenuResult::close();
assert!(result.should_close);
let result = ContextMenuResult::clicked("copy".to_string());
assert!(result.should_close);
assert_eq!(result.clicked_item, Some("copy".to_string()));
let result = ContextMenuResult::hovered(2);
assert!(!result.should_close);
assert_eq!(result.hovered_index, Some(2));
}
}