1use std::fmt;
2
3use crate::{EditorBuffer, KeyCode, Modifiers, Selection};
4
5pub const CUT_ID: &str = "cut";
11pub const COPY_ID: &str = "copy";
13pub const PASTE_ID: &str = "paste";
15pub const SELECT_ALL_ID: &str = "select_all";
17pub const UNDO_ID: &str = "undo";
19pub const REDO_ID: &str = "redo";
21pub const DELETE_ID: &str = "delete";
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30pub struct ContextMenuCaps {
31 pub has_selection: bool,
33 pub clipboard_has_text: bool,
35 pub can_undo: bool,
37 pub can_redo: bool,
39 pub is_full_doc_selected: bool,
41}
42
43impl ContextMenuCaps {
44 pub fn from_buffer(
46 buffer: &EditorBuffer,
47 selection: Option<&Selection>,
48 clipboard_has_text: bool,
49 ) -> Self {
50 let has_selection = selection.is_some_and(|s| !s.byte_range().is_empty());
51 let is_full_doc_selected = selection.is_some_and(|s| {
52 let range = s.byte_range();
53 range.start == 0 && range.end == buffer.len_bytes() && !range.is_empty()
54 });
55 Self {
56 has_selection,
57 clipboard_has_text,
58 can_undo: buffer.can_undo(),
59 can_redo: buffer.can_redo(),
60 is_full_doc_selected,
61 }
62 }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct ContextMenuItem {
71 pub id: &'static str,
73 pub label: String,
75 pub hint: Option<KeyHint>,
77 pub enabled: bool,
79 pub divider_after: bool,
81}
82
83impl ContextMenuItem {
84 pub fn new(id: &'static str, label: &str) -> Self {
86 Self {
87 id,
88 label: label.to_string(),
89 hint: None,
90 enabled: true,
91 divider_after: false,
92 }
93 }
94
95 pub fn with_hint(id: &'static str, label: &str, hint: KeyHint) -> Self {
97 Self {
98 id,
99 label: label.to_string(),
100 hint: Some(hint),
101 enabled: true,
102 divider_after: false,
103 }
104 }
105
106 pub fn disabled(mut self) -> Self {
108 self.enabled = false;
109 self
110 }
111
112 pub fn with_divider(mut self) -> Self {
114 self.divider_after = true;
115 self
116 }
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub struct KeyHint {
126 pub modifiers: Modifiers,
128 pub code: KeyCode,
130}
131
132impl KeyHint {
133 pub fn new(code: KeyCode, modifiers: Modifiers) -> Self {
137 let code = match code {
138 KeyCode::Char(c) if c.is_ascii_lowercase() => KeyCode::Char(c.to_ascii_uppercase()),
139 other => other,
140 };
141 Self { modifiers, code }
142 }
143
144 pub fn ctrl(code: KeyCode) -> Self {
146 Self::new(code, Modifiers::ctrl())
147 }
148
149 pub fn alt(code: KeyCode) -> Self {
151 Self::new(code, Modifiers::alt())
152 }
153
154 pub fn shift(code: KeyCode) -> Self {
156 Self::new(code, Modifiers::shift())
157 }
158
159 pub fn meta(code: KeyCode) -> Self {
161 Self::new(code, Modifiers::meta())
162 }
163
164 pub fn parts(&self) -> Vec<String> {
168 let mut parts = Vec::with_capacity(5);
169 if self.modifiers.ctrl {
170 parts.push("Ctrl".to_string());
171 }
172 if self.modifiers.alt {
173 parts.push("Alt".to_string());
174 }
175 if self.modifiers.shift {
176 parts.push("Shift".to_string());
177 }
178 if self.modifiers.meta {
179 parts.push("Meta".to_string());
180 }
181 parts.push(self.code.display());
182 parts
183 }
184}
185
186impl fmt::Display for KeyHint {
187 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188 write!(f, "{}", self.parts().join("+"))
189 }
190}
191
192pub struct ContextMenuContext<'a> {
199 pub buffer: &'a EditorBuffer,
201 pub selection: Option<&'a Selection>,
203 pub cursor_offset: usize,
205 pub clicked_row: usize,
207 pub clicked_col: usize,
209 pub caps: ContextMenuCaps,
211}
212
213#[derive(Debug, Clone, Default)]
221pub struct ContextMenuState {
222 items: Vec<ContextMenuItem>,
223 open: bool,
224}
225
226impl ContextMenuState {
227 pub fn new() -> Self {
229 Self::default()
230 }
231
232 pub fn is_open(&self) -> bool {
234 self.open
235 }
236
237 pub fn items(&self) -> &[ContextMenuItem] {
239 &self.items
240 }
241
242 pub fn open(&mut self, items: Vec<ContextMenuItem>) {
244 self.items = items;
245 self.open = true;
246 }
247
248 pub fn close(&mut self) {
250 self.items.clear();
251 self.open = false;
252 }
253}
254
255pub fn default_context_items(caps: ContextMenuCaps) -> Vec<ContextMenuItem> {
262 let mut items = vec![
263 with_enabled(
264 ContextMenuItem::with_hint(UNDO_ID, "Undo", KeyHint::ctrl(KeyCode::Char('Z'))),
265 caps.can_undo,
266 ),
267 with_enabled(
268 ContextMenuItem::with_hint(REDO_ID, "Redo", KeyHint::ctrl(KeyCode::Char('Y'))),
269 caps.can_redo,
270 ),
271 with_enabled(
272 ContextMenuItem::with_hint(CUT_ID, "Cut", KeyHint::ctrl(KeyCode::Char('X'))),
273 caps.has_selection,
274 ),
275 with_enabled(
276 ContextMenuItem::with_hint(COPY_ID, "Copy", KeyHint::ctrl(KeyCode::Char('C'))),
277 caps.has_selection,
278 ),
279 with_enabled(
280 ContextMenuItem::with_hint(PASTE_ID, "Paste", KeyHint::ctrl(KeyCode::Char('V'))),
281 caps.clipboard_has_text,
282 ),
283 with_enabled(
284 ContextMenuItem::new(DELETE_ID, "Delete"),
285 caps.has_selection,
286 ),
287 ];
288 let select_all = if caps.is_full_doc_selected {
289 ContextMenuItem::with_hint(
290 SELECT_ALL_ID,
291 "Select All",
292 KeyHint::ctrl(KeyCode::Char('A')),
293 )
294 .disabled()
295 } else {
296 ContextMenuItem::with_hint(
297 SELECT_ALL_ID,
298 "Select All",
299 KeyHint::ctrl(KeyCode::Char('A')),
300 )
301 };
302 items.push(select_all.with_divider());
303 items
304}
305
306fn with_enabled(mut item: ContextMenuItem, enabled: bool) -> ContextMenuItem {
307 item.enabled = enabled;
308 item
309}
310
311pub fn collect_context_items(
320 include_defaults: bool,
321 caps: ContextMenuCaps,
322 hook_rows: Vec<Vec<ContextMenuItem>>,
323) -> Vec<ContextMenuItem> {
324 let mut merged: Vec<ContextMenuItem> = if include_defaults {
325 default_context_items(caps)
326 } else {
327 Vec::new()
328 };
329 let mut hook_count = 0;
330 for rows in hook_rows {
331 for row in rows {
332 if let Some(pos) = merged.iter().position(|m| m.id == row.id) {
333 merged[pos] = row;
334 } else {
335 merged.push(row);
336 hook_count += 1;
337 }
338 }
339 }
340 if hook_count == 0
341 && let Some(last) = merged.last_mut()
342 {
343 last.divider_after = false;
344 }
345 merged
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351
352 fn caps_all() -> ContextMenuCaps {
353 ContextMenuCaps {
354 has_selection: true,
355 clipboard_has_text: true,
356 can_undo: true,
357 can_redo: true,
358 is_full_doc_selected: false,
359 }
360 }
361
362 #[test]
363 fn defaults_enablement_matrix() {
364 let items = default_context_items(ContextMenuCaps::default());
365 for item in &items {
367 if item.id == SELECT_ALL_ID {
368 assert!(item.enabled);
369 } else {
370 assert!(!item.enabled, "{} should be disabled", item.id);
371 }
372 }
373 assert!(items.last().unwrap().divider_after);
375
376 let items = default_context_items(caps_all());
377 assert!(items.iter().all(|i| i.enabled));
378 }
379
380 #[test]
381 fn select_all_disabled_when_full_doc_selected() {
382 let caps = ContextMenuCaps {
383 is_full_doc_selected: true,
384 ..caps_all()
385 };
386 let select = default_context_items(caps)
387 .into_iter()
388 .find(|i| i.id == SELECT_ALL_ID)
389 .unwrap();
390 assert!(!select.enabled);
391 }
392
393 #[test]
394 fn state_open_close_lifecycle() {
395 let mut state = ContextMenuState::new();
396 assert!(!state.is_open());
397 state.open(default_context_items(caps_all()));
398 assert!(state.is_open());
399 assert_eq!(state.items().len(), 7);
400 state.close();
401 assert!(!state.is_open());
402 assert!(state.items().is_empty());
403 }
404
405 #[test]
406 fn collect_merges_hooks_after_defaults() {
407 let merged = collect_context_items(
408 true,
409 caps_all(),
410 vec![vec![ContextMenuItem::new("md.toggle-task", "Toggle task")]],
411 );
412 assert_eq!(merged.len(), 8);
413 assert_eq!(merged[7].id, "md.toggle-task");
414 assert!(merged[6].divider_after);
416 }
417
418 #[test]
419 fn collect_drops_trailing_divider_without_hooks() {
420 let merged = collect_context_items(true, caps_all(), vec![]);
421 assert!(!merged.last().unwrap().divider_after);
422 }
423
424 #[test]
425 fn collect_hook_overrides_default_by_id() {
426 let merged = collect_context_items(
427 true,
428 ContextMenuCaps::default(),
429 vec![vec![ContextMenuItem::new(COPY_ID, "Copy link")]],
430 );
431 let copy = merged.iter().find(|i| i.id == COPY_ID).unwrap();
432 assert_eq!(copy.label, "Copy link");
433 assert!(copy.enabled);
434 assert_eq!(merged.len(), 7);
436 }
437
438 #[test]
439 fn collect_without_defaults_uses_hooks_only() {
440 let merged = collect_context_items(
441 false,
442 ContextMenuCaps::default(),
443 vec![vec![ContextMenuItem::new("custom", "Custom")]],
444 );
445 assert_eq!(merged.len(), 1);
446 }
447
448 #[test]
449 fn key_hint_display_is_canonical_order() {
450 let hint = KeyHint::new(
451 KeyCode::Char('z'),
452 Modifiers {
453 shift: true,
454 ctrl: true,
455 ..Modifiers::empty()
456 },
457 );
458 assert_eq!(hint.to_string(), "Ctrl+Shift+Z");
459 assert_eq!(hint.parts(), vec!["Ctrl", "Shift", "Z"]);
460 }
461
462 #[test]
463 fn key_hint_single_letters_normalize_case() {
464 assert_eq!(
467 KeyHint::new(KeyCode::Char('l'), Modifiers::ctrl()),
468 KeyHint::ctrl(KeyCode::Char('L'))
469 );
470 assert_eq!(KeyHint::ctrl(KeyCode::Char('l')).to_string(), "Ctrl+L");
471 }
472
473 #[test]
474 fn key_hint_named_keys_render_mixed() {
475 assert_eq!(KeyHint::ctrl(KeyCode::Enter).to_string(), "Ctrl+Enter");
476 assert_eq!(KeyHint::ctrl(KeyCode::Char(' ')).to_string(), "Ctrl+ ");
477 assert_eq!(KeyHint::ctrl(KeyCode::Escape).to_string(), "Ctrl+Esc");
478 assert_eq!(
479 KeyHint::ctrl(KeyCode::Backspace).to_string(),
480 "Ctrl+Backspace"
481 );
482 assert_eq!(KeyHint::ctrl(KeyCode::Delete).to_string(), "Ctrl+Delete");
483 assert_eq!(KeyHint::ctrl(KeyCode::Up).to_string(), "Ctrl+↑");
484 assert_eq!(
485 KeyHint::new(KeyCode::F(5), Modifiers::empty()).to_string(),
486 "F5"
487 );
488 assert_eq!(
489 KeyHint::new(KeyCode::Char('/'), Modifiers::empty()).to_string(),
490 "/"
491 );
492 }
493
494 #[test]
495 fn builtin_rows_carry_structured_hints() {
496 let items = default_context_items(caps_all());
497 let undo = items.iter().find(|i| i.id == UNDO_ID).unwrap();
498 assert_eq!(undo.hint, Some(KeyHint::ctrl(KeyCode::Char('Z'))));
499 assert_eq!(undo.hint.as_ref().unwrap().to_string(), "Ctrl+Z");
500 let delete = items.iter().find(|i| i.id == DELETE_ID).unwrap();
501 assert_eq!(delete.hint, None);
502 }
503
504 #[test]
505 fn caps_from_buffer_derives_selection_and_history() {
506 let mut buffer = EditorBuffer::new("hello world");
507 buffer.insert("!");
508 let sel = Selection::range(0, 5);
509 let caps = ContextMenuCaps::from_buffer(&buffer, Some(&sel), true);
510 assert!(caps.has_selection);
511 assert!(caps.clipboard_has_text);
512 assert!(caps.can_undo);
513 assert!(!caps.can_redo);
514 assert!(!caps.is_full_doc_selected);
515
516 let full = Selection::range(0, buffer.len_bytes());
517 let caps = ContextMenuCaps::from_buffer(&buffer, Some(&full), false);
518 assert!(caps.is_full_doc_selected);
519
520 let caps = ContextMenuCaps::from_buffer(&buffer, None, false);
521 assert!(!caps.has_selection);
522 }
523}