Skip to main content

twrite_core/
context_menu.rs

1use std::fmt;
2
3use crate::{EditorBuffer, KeyCode, Modifiers, Selection};
4
5/// Well-known context menu item ids for the built-in edit actions.
6///
7/// Hooks may return an item with one of these ids to override the
8/// corresponding default (label, hint, enabled state); the hook's
9/// version wins and keeps the default's position.
10pub const CUT_ID: &str = "cut";
11/// Well-known id for the built-in Copy action.
12pub const COPY_ID: &str = "copy";
13/// Well-known id for the built-in Paste action.
14pub const PASTE_ID: &str = "paste";
15/// Well-known id for the built-in Select All action.
16pub const SELECT_ALL_ID: &str = "select_all";
17/// Well-known id for the built-in Undo action.
18pub const UNDO_ID: &str = "undo";
19/// Well-known id for the built-in Redo action.
20pub const REDO_ID: &str = "redo";
21/// Well-known id for the built-in Delete action.
22pub const DELETE_ID: &str = "delete";
23
24/// Frontend-supplied capabilities the core cannot observe headlessly.
25///
26/// The OS clipboard lives outside `twrite-core`; GPUI hosts read it once
27/// per right-click and pass the result in. Everything else derives from
28/// the buffer + selection.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30pub struct ContextMenuCaps {
31    /// Whether a non-empty selection exists.
32    pub has_selection: bool,
33    /// Whether the OS clipboard currently holds text (supplied by the host).
34    pub clipboard_has_text: bool,
35    /// Whether an undo transaction is available.
36    pub can_undo: bool,
37    /// Whether a redo transaction is available.
38    pub can_redo: bool,
39    /// Whether the selection already spans the whole document.
40    pub is_full_doc_selected: bool,
41}
42
43impl ContextMenuCaps {
44    /// Derives buffer-observable caps; the host ORs in `clipboard_has_text`.
45    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/// One row in the right-click context menu.
66///
67/// Hooks own the meaning of custom ids; frontends only draw rows and
68/// route clicks back through `on_context_menu_action` / built-in dispatch.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct ContextMenuItem {
71    /// Stable id (`"cut"`, `"copy"`, ... or hook-defined e.g. `"md.toggle-task"`).
72    pub id: &'static str,
73    /// Primary row text.
74    pub label: String,
75    /// Structured keybinding hint, rendered as right-aligned kbd chips.
76    pub hint: Option<KeyHint>,
77    /// Whether the row is clickable. Disabled rows render dimmed.
78    pub enabled: bool,
79    /// Whether a separator renders directly below this row.
80    pub divider_after: bool,
81}
82
83impl ContextMenuItem {
84    /// Creates an enabled item with no hint.
85    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    /// Creates an enabled item with a structured keybinding hint.
96    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    /// Marks the item as disabled (renders dimmed, ignores clicks).
107    pub fn disabled(mut self) -> Self {
108        self.enabled = false;
109        self
110    }
111
112    /// Requests a separator directly below this row.
113    pub fn with_divider(mut self) -> Self {
114        self.divider_after = true;
115        self
116    }
117}
118
119/// Structured keybinding hint for menu rows.
120///
121/// Display is canonicalized (`Ctrl+Shift+Z`), so inconsistent free text
122/// (`"ctrl + l"` vs `"Ctrl+U"`) is impossible by construction. The hinted
123/// key is a [`KeyCode`], shared with hook matching and keymaps.
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub struct KeyHint {
126    /// Active modifiers, rendered first in canonical Ctrl, Alt, Shift, Meta order.
127    pub modifiers: Modifiers,
128    /// The hinted key.
129    pub code: KeyCode,
130}
131
132impl KeyHint {
133    /// Creates a hint. Single ASCII lowercase `Char` keys uppercase for
134    /// canonical form (`Char('l')` and `Char('L')` compare and render
135    /// identically); everything else is stored verbatim.
136    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    /// Single-modifier helpers for the common cases.
145    pub fn ctrl(code: KeyCode) -> Self {
146        Self::new(code, Modifiers::ctrl())
147    }
148
149    /// Single-modifier helper (Alt / Option).
150    pub fn alt(code: KeyCode) -> Self {
151        Self::new(code, Modifiers::alt())
152    }
153
154    /// Single-modifier helper.
155    pub fn shift(code: KeyCode) -> Self {
156        Self::new(code, Modifiers::shift())
157    }
158
159    /// Single-modifier helper (Meta / Command / Windows).
160    pub fn meta(code: KeyCode) -> Self {
161        Self::new(code, Modifiers::meta())
162    }
163
164    /// Display parts in render order: active modifiers first (canonical
165    /// Ctrl, Alt, Shift, Meta order), then the display key. One kbd chip
166    /// per part.
167    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
192/// Read-only snapshot passed to hooks contributing menu items.
193///
194/// `clicked_row` / `clicked_col` are buffer coordinates of the right-click
195/// (row = zero-based buffer row, col = source byte offset within that
196/// line's text, clamped to the line length) so hooks can offer
197/// position-aware actions (e.g. "Toggle task checkbox" only on task lines).
198pub struct ContextMenuContext<'a> {
199    /// The underlying text buffer (read-only for item contribution).
200    pub buffer: &'a EditorBuffer,
201    /// The active selection range, if any.
202    pub selection: Option<&'a Selection>,
203    /// Current cursor byte offset.
204    pub cursor_offset: usize,
205    /// Zero-based buffer row that was right-clicked.
206    pub clicked_row: usize,
207    /// Source byte offset within the clicked line that was right-clicked.
208    pub clicked_col: usize,
209    /// Frontend-supplied capabilities (clipboard, undo/redo availability).
210    pub caps: ContextMenuCaps,
211}
212
213/// Headless open/closed menu state + resolved item list.
214///
215/// Owned by the host (the GPUI `Editor`) mirroring `PromptState`: hooks
216/// contribute items via `EditorHook::context_menu_items`, the host merges
217/// with [`default_context_items`] via [`collect_context_items`], and the
218/// frontend only draws rows. Click position (pixels) stays in the GPUI
219/// layer; this type never touches screen coordinates.
220#[derive(Debug, Clone, Default)]
221pub struct ContextMenuState {
222    items: Vec<ContextMenuItem>,
223    open: bool,
224}
225
226impl ContextMenuState {
227    /// Creates a closed menu.
228    pub fn new() -> Self {
229        Self::default()
230    }
231
232    /// Whether a menu is currently open.
233    pub fn is_open(&self) -> bool {
234        self.open
235    }
236
237    /// Current item rows.
238    pub fn items(&self) -> &[ContextMenuItem] {
239        &self.items
240    }
241
242    /// Opens the menu with a pre-merged item list.
243    pub fn open(&mut self, items: Vec<ContextMenuItem>) {
244        self.items = items;
245        self.open = true;
246    }
247
248    /// Closes the menu, clearing the item list.
249    pub fn close(&mut self) {
250        self.items.clear();
251        self.open = false;
252    }
253}
254
255/// Builds the built-in edit rows for the given capabilities.
256///
257/// Order: Undo, Redo, Cut, Copy, Paste, Delete, Select All. The last
258/// default carries `divider_after = true` so renderers draw a separator
259/// before hook-contributed rows (removed by [`collect_context_items`]
260/// when no hook rows follow).
261pub 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
311/// Merges defaults with hook-contributed rows.
312///
313/// * When `include_defaults` is false, only hook rows are used.
314/// * Hook rows append in order after the defaults.
315/// * A hook row whose `id` matches a default (or an earlier hook row)
316///   replaces it in place, keeping the original position.
317/// * The trailing divider on the defaults block is dropped when no hook
318///   rows follow, so a defaults-only menu has no stray separator.
319pub 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        // Nothing available: only Select All enabled.
366        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        // Last default carries the hook-block separator.
374        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        // Hook rows present: defaults-block divider retained.
415        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        // Override replaces in place: no extra row.
435        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        // 'l' and 'L' are the same hint: the old free-text drift
465        // ("ctrl + l" vs "Ctrl+U") cannot be expressed anymore.
466        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}