use std::fmt;
use crate::{EditorBuffer, KeyCode, Modifiers, Selection};
pub const CUT_ID: &str = "cut";
pub const COPY_ID: &str = "copy";
pub const PASTE_ID: &str = "paste";
pub const SELECT_ALL_ID: &str = "select_all";
pub const UNDO_ID: &str = "undo";
pub const REDO_ID: &str = "redo";
pub const DELETE_ID: &str = "delete";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ContextMenuCaps {
pub has_selection: bool,
pub clipboard_has_text: bool,
pub can_undo: bool,
pub can_redo: bool,
pub is_full_doc_selected: bool,
}
impl ContextMenuCaps {
pub fn from_buffer(
buffer: &EditorBuffer,
selection: Option<&Selection>,
clipboard_has_text: bool,
) -> Self {
let has_selection = selection.is_some_and(|s| !s.byte_range().is_empty());
let is_full_doc_selected = selection.is_some_and(|s| {
let range = s.byte_range();
range.start == 0 && range.end == buffer.len_bytes() && !range.is_empty()
});
Self {
has_selection,
clipboard_has_text,
can_undo: buffer.can_undo(),
can_redo: buffer.can_redo(),
is_full_doc_selected,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContextMenuItem {
pub id: &'static str,
pub label: String,
pub hint: Option<KeyHint>,
pub enabled: bool,
pub divider_after: bool,
}
impl ContextMenuItem {
pub fn new(id: &'static str, label: &str) -> Self {
Self {
id,
label: label.to_string(),
hint: None,
enabled: true,
divider_after: false,
}
}
pub fn with_hint(id: &'static str, label: &str, hint: KeyHint) -> Self {
Self {
id,
label: label.to_string(),
hint: Some(hint),
enabled: true,
divider_after: false,
}
}
pub fn disabled(mut self) -> Self {
self.enabled = false;
self
}
pub fn with_divider(mut self) -> Self {
self.divider_after = true;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct KeyHint {
pub modifiers: Modifiers,
pub code: KeyCode,
}
impl KeyHint {
pub fn new(code: KeyCode, modifiers: Modifiers) -> Self {
let code = match code {
KeyCode::Char(c) if c.is_ascii_lowercase() => KeyCode::Char(c.to_ascii_uppercase()),
other => other,
};
Self { modifiers, code }
}
pub fn ctrl(code: KeyCode) -> Self {
Self::new(code, Modifiers::ctrl())
}
pub fn alt(code: KeyCode) -> Self {
Self::new(code, Modifiers::alt())
}
pub fn shift(code: KeyCode) -> Self {
Self::new(code, Modifiers::shift())
}
pub fn meta(code: KeyCode) -> Self {
Self::new(code, Modifiers::meta())
}
pub fn parts(&self) -> Vec<String> {
let mut parts = Vec::with_capacity(5);
if self.modifiers.ctrl {
parts.push("Ctrl".to_string());
}
if self.modifiers.alt {
parts.push("Alt".to_string());
}
if self.modifiers.shift {
parts.push("Shift".to_string());
}
if self.modifiers.meta {
parts.push("Meta".to_string());
}
parts.push(self.code.display());
parts
}
}
impl fmt::Display for KeyHint {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.parts().join("+"))
}
}
pub struct ContextMenuContext<'a> {
pub buffer: &'a EditorBuffer,
pub selection: Option<&'a Selection>,
pub cursor_offset: usize,
pub clicked_row: usize,
pub clicked_col: usize,
pub caps: ContextMenuCaps,
}
#[derive(Debug, Clone, Default)]
pub struct ContextMenuState {
items: Vec<ContextMenuItem>,
open: bool,
}
impl ContextMenuState {
pub fn new() -> Self {
Self::default()
}
pub fn is_open(&self) -> bool {
self.open
}
pub fn items(&self) -> &[ContextMenuItem] {
&self.items
}
pub fn open(&mut self, items: Vec<ContextMenuItem>) {
self.items = items;
self.open = true;
}
pub fn close(&mut self) {
self.items.clear();
self.open = false;
}
}
pub fn default_context_items(caps: ContextMenuCaps) -> Vec<ContextMenuItem> {
let mut items = vec![
with_enabled(
ContextMenuItem::with_hint(UNDO_ID, "Undo", KeyHint::ctrl(KeyCode::Char('Z'))),
caps.can_undo,
),
with_enabled(
ContextMenuItem::with_hint(REDO_ID, "Redo", KeyHint::ctrl(KeyCode::Char('Y'))),
caps.can_redo,
),
with_enabled(
ContextMenuItem::with_hint(CUT_ID, "Cut", KeyHint::ctrl(KeyCode::Char('X'))),
caps.has_selection,
),
with_enabled(
ContextMenuItem::with_hint(COPY_ID, "Copy", KeyHint::ctrl(KeyCode::Char('C'))),
caps.has_selection,
),
with_enabled(
ContextMenuItem::with_hint(PASTE_ID, "Paste", KeyHint::ctrl(KeyCode::Char('V'))),
caps.clipboard_has_text,
),
with_enabled(
ContextMenuItem::new(DELETE_ID, "Delete"),
caps.has_selection,
),
];
let select_all = if caps.is_full_doc_selected {
ContextMenuItem::with_hint(
SELECT_ALL_ID,
"Select All",
KeyHint::ctrl(KeyCode::Char('A')),
)
.disabled()
} else {
ContextMenuItem::with_hint(
SELECT_ALL_ID,
"Select All",
KeyHint::ctrl(KeyCode::Char('A')),
)
};
items.push(select_all.with_divider());
items
}
fn with_enabled(mut item: ContextMenuItem, enabled: bool) -> ContextMenuItem {
item.enabled = enabled;
item
}
pub fn collect_context_items(
include_defaults: bool,
caps: ContextMenuCaps,
hook_rows: Vec<Vec<ContextMenuItem>>,
) -> Vec<ContextMenuItem> {
let mut merged: Vec<ContextMenuItem> = if include_defaults {
default_context_items(caps)
} else {
Vec::new()
};
let mut hook_count = 0;
for rows in hook_rows {
for row in rows {
if let Some(pos) = merged.iter().position(|m| m.id == row.id) {
merged[pos] = row;
} else {
merged.push(row);
hook_count += 1;
}
}
}
if hook_count == 0
&& let Some(last) = merged.last_mut()
{
last.divider_after = false;
}
merged
}
#[cfg(test)]
mod tests {
use super::*;
fn caps_all() -> ContextMenuCaps {
ContextMenuCaps {
has_selection: true,
clipboard_has_text: true,
can_undo: true,
can_redo: true,
is_full_doc_selected: false,
}
}
#[test]
fn defaults_enablement_matrix() {
let items = default_context_items(ContextMenuCaps::default());
for item in &items {
if item.id == SELECT_ALL_ID {
assert!(item.enabled);
} else {
assert!(!item.enabled, "{} should be disabled", item.id);
}
}
assert!(items.last().unwrap().divider_after);
let items = default_context_items(caps_all());
assert!(items.iter().all(|i| i.enabled));
}
#[test]
fn select_all_disabled_when_full_doc_selected() {
let caps = ContextMenuCaps {
is_full_doc_selected: true,
..caps_all()
};
let select = default_context_items(caps)
.into_iter()
.find(|i| i.id == SELECT_ALL_ID)
.unwrap();
assert!(!select.enabled);
}
#[test]
fn state_open_close_lifecycle() {
let mut state = ContextMenuState::new();
assert!(!state.is_open());
state.open(default_context_items(caps_all()));
assert!(state.is_open());
assert_eq!(state.items().len(), 7);
state.close();
assert!(!state.is_open());
assert!(state.items().is_empty());
}
#[test]
fn collect_merges_hooks_after_defaults() {
let merged = collect_context_items(
true,
caps_all(),
vec![vec![ContextMenuItem::new("md.toggle-task", "Toggle task")]],
);
assert_eq!(merged.len(), 8);
assert_eq!(merged[7].id, "md.toggle-task");
assert!(merged[6].divider_after);
}
#[test]
fn collect_drops_trailing_divider_without_hooks() {
let merged = collect_context_items(true, caps_all(), vec![]);
assert!(!merged.last().unwrap().divider_after);
}
#[test]
fn collect_hook_overrides_default_by_id() {
let merged = collect_context_items(
true,
ContextMenuCaps::default(),
vec![vec![ContextMenuItem::new(COPY_ID, "Copy link")]],
);
let copy = merged.iter().find(|i| i.id == COPY_ID).unwrap();
assert_eq!(copy.label, "Copy link");
assert!(copy.enabled);
assert_eq!(merged.len(), 7);
}
#[test]
fn collect_without_defaults_uses_hooks_only() {
let merged = collect_context_items(
false,
ContextMenuCaps::default(),
vec![vec![ContextMenuItem::new("custom", "Custom")]],
);
assert_eq!(merged.len(), 1);
}
#[test]
fn key_hint_display_is_canonical_order() {
let hint = KeyHint::new(
KeyCode::Char('z'),
Modifiers {
shift: true,
ctrl: true,
..Modifiers::empty()
},
);
assert_eq!(hint.to_string(), "Ctrl+Shift+Z");
assert_eq!(hint.parts(), vec!["Ctrl", "Shift", "Z"]);
}
#[test]
fn key_hint_single_letters_normalize_case() {
assert_eq!(
KeyHint::new(KeyCode::Char('l'), Modifiers::ctrl()),
KeyHint::ctrl(KeyCode::Char('L'))
);
assert_eq!(KeyHint::ctrl(KeyCode::Char('l')).to_string(), "Ctrl+L");
}
#[test]
fn key_hint_named_keys_render_mixed() {
assert_eq!(KeyHint::ctrl(KeyCode::Enter).to_string(), "Ctrl+Enter");
assert_eq!(KeyHint::ctrl(KeyCode::Char(' ')).to_string(), "Ctrl+ ");
assert_eq!(KeyHint::ctrl(KeyCode::Escape).to_string(), "Ctrl+Esc");
assert_eq!(
KeyHint::ctrl(KeyCode::Backspace).to_string(),
"Ctrl+Backspace"
);
assert_eq!(KeyHint::ctrl(KeyCode::Delete).to_string(), "Ctrl+Delete");
assert_eq!(KeyHint::ctrl(KeyCode::Up).to_string(), "Ctrl+↑");
assert_eq!(
KeyHint::new(KeyCode::F(5), Modifiers::empty()).to_string(),
"F5"
);
assert_eq!(
KeyHint::new(KeyCode::Char('/'), Modifiers::empty()).to_string(),
"/"
);
}
#[test]
fn builtin_rows_carry_structured_hints() {
let items = default_context_items(caps_all());
let undo = items.iter().find(|i| i.id == UNDO_ID).unwrap();
assert_eq!(undo.hint, Some(KeyHint::ctrl(KeyCode::Char('Z'))));
assert_eq!(undo.hint.as_ref().unwrap().to_string(), "Ctrl+Z");
let delete = items.iter().find(|i| i.id == DELETE_ID).unwrap();
assert_eq!(delete.hint, None);
}
#[test]
fn caps_from_buffer_derives_selection_and_history() {
let mut buffer = EditorBuffer::new("hello world");
buffer.insert("!");
let sel = Selection::range(0, 5);
let caps = ContextMenuCaps::from_buffer(&buffer, Some(&sel), true);
assert!(caps.has_selection);
assert!(caps.clipboard_has_text);
assert!(caps.can_undo);
assert!(!caps.can_redo);
assert!(!caps.is_full_doc_selected);
let full = Selection::range(0, buffer.len_bytes());
let caps = ContextMenuCaps::from_buffer(&buffer, Some(&full), false);
assert!(caps.is_full_doc_selected);
let caps = ContextMenuCaps::from_buffer(&buffer, None, false);
assert!(!caps.has_selection);
}
}