use crate::commands::CommandId;
#[derive(Debug, Clone)]
pub struct ContextMenuItem {
pub label: String,
pub command: CommandId,
pub enabled: bool,
}
impl ContextMenuItem {
pub fn new(label: impl Into<String>, command: CommandId) -> Self {
Self { label: label.into(), command, enabled: true }
}
pub fn with_enabled(label: impl Into<String>, command: CommandId, enabled: bool) -> Self {
Self { label: label.into(), command, enabled }
}
}
#[derive(Debug, Clone)]
pub struct ContextMenu {
pub items: Vec<ContextMenuItem>,
pub cursor: usize,
pub x: u16,
pub y: u16,
}
impl ContextMenu {
pub fn new(items: Vec<ContextMenuItem>, x: u16, y: u16) -> Self {
let cursor = items.iter().position(|i| i.enabled).unwrap_or(0);
Self { items, cursor, x, y }
}
pub fn move_up(&mut self) {
if self.items.is_empty() { return; }
let n = self.items.len();
let mut idx = self.cursor;
for _ in 0..n {
idx = if idx == 0 { n - 1 } else { idx - 1 };
if self.items[idx].enabled {
self.cursor = idx;
return;
}
}
}
pub fn move_down(&mut self) {
if self.items.is_empty() { return; }
let n = self.items.len();
let mut idx = self.cursor;
for _ in 0..n {
idx = (idx + 1) % n;
if self.items[idx].enabled {
self.cursor = idx;
return;
}
}
}
pub fn selected_command(&self) -> Option<&CommandId> {
self.items.get(self.cursor).and_then(|it| if it.enabled { Some(&it.command) } else { None })
}
pub fn clamped(mut self, screen_width: u16, screen_height: u16) -> Self {
let menu_w = self.items.iter().map(|i| i.label.len() + 4).max().unwrap_or(10) as u16;
let menu_h = self.items.len() as u16 + 2; if self.x + menu_w > screen_width {
self.x = screen_width.saturating_sub(menu_w);
}
if self.y + menu_h > screen_height {
self.y = screen_height.saturating_sub(menu_h);
}
self
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cmd(name: &'static str) -> CommandId {
CommandId::new_static("test", name)
}
fn three_item_menu() -> ContextMenu {
ContextMenu::new(
vec![
ContextMenuItem::new("New Tab", cmd("new_tab")),
ContextMenuItem::new("Rename Tab", cmd("rename_tab")),
ContextMenuItem::new("Close Tab", cmd("close_tab")),
],
5,
10,
)
}
#[test]
fn initial_cursor_is_zero() {
let m = three_item_menu();
assert_eq!(m.cursor, 0);
}
#[test]
fn move_down_advances_cursor() {
let mut m = three_item_menu();
m.move_down();
assert_eq!(m.cursor, 1);
m.move_down();
assert_eq!(m.cursor, 2);
}
#[test]
fn move_down_wraps_around() {
let mut m = three_item_menu();
m.cursor = 2;
m.move_down();
assert_eq!(m.cursor, 0);
}
#[test]
fn move_up_wraps_around() {
let mut m = three_item_menu();
m.move_up();
assert_eq!(m.cursor, 2); }
#[test]
fn move_up_decrements_cursor() {
let mut m = three_item_menu();
m.cursor = 2;
m.move_up();
assert_eq!(m.cursor, 1);
}
#[test]
fn selected_command_returns_correct_item() {
let mut m = three_item_menu();
assert_eq!(m.selected_command(), Some(&cmd("new_tab")));
m.cursor = 2;
assert_eq!(m.selected_command(), Some(&cmd("close_tab")));
}
#[test]
fn selected_command_on_empty_menu_is_none() {
let m = ContextMenu::new(vec![], 0, 0);
assert_eq!(m.selected_command(), None);
}
#[test]
fn clamped_does_not_move_when_fits() {
let m = three_item_menu().clamped(80, 24);
assert_eq!(m.x, 5);
assert_eq!(m.y, 10);
}
#[test]
fn clamped_shifts_left_when_overflows_right_edge() {
let m = ContextMenu::new(
vec![
ContextMenuItem::new("Rename Tab", cmd("rename_tab")),
],
76, 5,
)
.clamped(80, 24);
assert!(m.x + 14 <= 80, "x={} should be clamped", m.x);
}
#[test]
fn clamped_shifts_up_when_overflows_bottom() {
let m = three_item_menu();
let m2 = ContextMenu { y: 22, ..m }.clamped(80, 24);
assert!(m2.y + 5 <= 24, "y={} should be clamped", m2.y);
}
}