Skip to main content

iced_code_editor/canvas_editor/
context_menu.rs

1//! Right-click context menu for editor actions.
2
3use iced::widget::{Space, button, column, container, row, text};
4use iced::{Background, Border, Color, Element, Length, Shadow, Theme, Vector};
5
6use super::Message;
7use crate::i18n::Translations;
8
9const MENU_WIDTH: f32 = 224.0;
10
11/// An actionable entry in the editor context menu.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct ContextMenuItem {
14    /// Stable action identifier emitted when the item is selected.
15    pub id: String,
16    /// Text displayed for the item.
17    pub label: String,
18    /// Optional keyboard shortcut hint displayed beside the label.
19    pub shortcut: Option<String>,
20    /// Whether the item can be selected.
21    pub enabled: bool,
22}
23
24impl ContextMenuItem {
25    /// Creates an enabled context-menu item without a shortcut hint.
26    pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
27        Self {
28            id: id.into(),
29            label: label.into(),
30            shortcut: None,
31            enabled: true,
32        }
33    }
34
35    /// Sets the keyboard shortcut hint displayed beside this item.
36    #[must_use]
37    pub fn with_shortcut(mut self, shortcut: impl Into<String>) -> Self {
38        self.shortcut = Some(shortcut.into());
39        self
40    }
41
42    /// Sets whether this item can be selected.
43    #[must_use]
44    pub fn with_enabled(mut self, enabled: bool) -> Self {
45        self.enabled = enabled;
46        self
47    }
48}
49
50/// A custom editor context-menu entry.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum ContextMenuEntry {
53    /// An actionable menu item.
54    Item(ContextMenuItem),
55    /// A visual separator between groups of items.
56    Separator,
57}
58
59impl ContextMenuEntry {
60    /// Creates an enabled action entry without a shortcut hint.
61    pub fn item(id: impl Into<String>, label: impl Into<String>) -> Self {
62        Self::Item(ContextMenuItem::new(id, label))
63    }
64
65    /// Creates a separator entry.
66    pub const fn separator() -> Self {
67        Self::Separator
68    }
69
70    /// Sets the keyboard shortcut hint when this is an action entry.
71    #[must_use]
72    pub fn with_shortcut(self, shortcut: impl Into<String>) -> Self {
73        match self {
74            Self::Item(item) => Self::Item(item.with_shortcut(shortcut)),
75            Self::Separator => Self::Separator,
76        }
77    }
78
79    /// Sets whether this entry can be selected when it is an action.
80    #[must_use]
81    pub fn with_enabled(self, enabled: bool) -> Self {
82        match self {
83            Self::Item(item) => Self::Item(item.with_enabled(enabled)),
84            Self::Separator => Self::Separator,
85        }
86    }
87}
88
89impl From<ContextMenuItem> for ContextMenuEntry {
90    fn from(item: ContextMenuItem) -> Self {
91        Self::Item(item)
92    }
93}
94
95#[cfg(target_os = "macos")]
96const UNDO_SHORTCUT: &str = "⌘Z";
97#[cfg(not(target_os = "macos"))]
98const UNDO_SHORTCUT: &str = "Ctrl+Z";
99
100#[cfg(target_os = "macos")]
101const REDO_SHORTCUT: &str = "⇧⌘Z";
102#[cfg(not(target_os = "macos"))]
103const REDO_SHORTCUT: &str = "Ctrl+Y";
104
105#[cfg(target_os = "macos")]
106const CUT_SHORTCUT: &str = "⌘X";
107#[cfg(not(target_os = "macos"))]
108const CUT_SHORTCUT: &str = "Ctrl+X";
109
110#[cfg(target_os = "macos")]
111const COPY_SHORTCUT: &str = "⌘C";
112#[cfg(not(target_os = "macos"))]
113const COPY_SHORTCUT: &str = "Ctrl+C";
114
115#[cfg(target_os = "macos")]
116const PASTE_SHORTCUT: &str = "⌘V";
117#[cfg(not(target_os = "macos"))]
118const PASTE_SHORTCUT: &str = "Ctrl+V";
119
120#[cfg(target_os = "macos")]
121const SELECT_ALL_SHORTCUT: &str = "⌘A";
122#[cfg(not(target_os = "macos"))]
123const SELECT_ALL_SHORTCUT: &str = "Ctrl+A";
124
125#[derive(Debug, Clone, Copy, Default)]
126pub(crate) struct MenuState {
127    pub(crate) can_undo: bool,
128    pub(crate) can_redo: bool,
129    pub(crate) has_selection: bool,
130    pub(crate) has_content: bool,
131    pub(crate) reveal_in_file_manager_enabled: bool,
132}
133
134#[derive(Debug, Clone)]
135enum MenuEntry {
136    Item { label: String, shortcut: String, message: Option<Message> },
137    Separator,
138}
139
140impl MenuEntry {
141    #[cfg(test)]
142    fn label(&self) -> Option<&str> {
143        match self {
144            Self::Item { label, .. } => Some(label),
145            Self::Separator => None,
146        }
147    }
148}
149
150fn custom_entries(entries: &[ContextMenuEntry]) -> Vec<MenuEntry> {
151    entries
152        .iter()
153        .map(|entry| match entry {
154            ContextMenuEntry::Item(item) => MenuEntry::Item {
155                label: item.label.clone(),
156                shortcut: item.shortcut.clone().unwrap_or_default(),
157                message: item
158                    .enabled
159                    .then(|| Message::CustomContextMenuAction(item.id.clone())),
160            },
161            ContextMenuEntry::Separator => MenuEntry::Separator,
162        })
163        .collect()
164}
165
166fn default_entries(
167    state: MenuState,
168    translations: &Translations,
169) -> Vec<MenuEntry> {
170    let mut entries = if state.reveal_in_file_manager_enabled {
171        vec![
172            MenuEntry::Item {
173                label: translations.context_menu_reveal_in_file_manager(),
174                shortcut: String::new(),
175                message: Some(Message::RevealInFileManager),
176            },
177            MenuEntry::Separator,
178        ]
179    } else {
180        Vec::new()
181    };
182    entries.extend([
183        MenuEntry::Item {
184            label: translations.context_menu_undo(),
185            shortcut: UNDO_SHORTCUT.to_string(),
186            message: state.can_undo.then_some(Message::Undo),
187        },
188        MenuEntry::Item {
189            label: translations.context_menu_redo(),
190            shortcut: REDO_SHORTCUT.to_string(),
191            message: state.can_redo.then_some(Message::Redo),
192        },
193        MenuEntry::Separator,
194        MenuEntry::Item {
195            label: translations.context_menu_cut(),
196            shortcut: CUT_SHORTCUT.to_string(),
197            message: state.has_selection.then_some(Message::Cut),
198        },
199        MenuEntry::Item {
200            label: translations.context_menu_copy(),
201            shortcut: COPY_SHORTCUT.to_string(),
202            message: state.has_selection.then_some(Message::Copy),
203        },
204        MenuEntry::Item {
205            label: translations.context_menu_paste(),
206            shortcut: PASTE_SHORTCUT.to_string(),
207            message: Some(Message::Paste(String::new())),
208        },
209        MenuEntry::Separator,
210        MenuEntry::Item {
211            label: translations.context_menu_select_all(),
212            shortcut: SELECT_ALL_SHORTCUT.to_string(),
213            message: state.has_content.then_some(Message::SelectAll),
214        },
215    ]);
216    entries
217}
218
219fn build_entries(
220    custom: &[ContextMenuEntry],
221    default_context_menu_enabled: bool,
222    state: MenuState,
223    translations: &Translations,
224) -> Vec<MenuEntry> {
225    let mut entries = custom_entries(custom);
226    if default_context_menu_enabled {
227        if !entries.is_empty() {
228            entries.push(MenuEntry::Separator);
229        }
230        entries.extend(default_entries(state, translations));
231    }
232    entries
233}
234
235/// Builds the context-menu contents.
236pub(crate) fn view(
237    custom: &[ContextMenuEntry],
238    default_context_menu_enabled: bool,
239    state: MenuState,
240    translations: Translations,
241) -> Element<'static, Message> {
242    let items = build_entries(
243        custom,
244        default_context_menu_enabled,
245        state,
246        &translations,
247    )
248    .into_iter()
249    .map(|entry| match entry {
250        MenuEntry::Item { label, shortcut, message } => {
251            menu_item(label, shortcut, message)
252        }
253        MenuEntry::Separator => separator(),
254    })
255    .collect::<Vec<_>>();
256
257    container(column(items).spacing(1).padding(4))
258        .width(Length::Fixed(MENU_WIDTH))
259        .style(|theme: &Theme| {
260            let palette = theme.extended_palette();
261            container::Style {
262                background: Some(Background::Color(
263                    palette.background.weak.color,
264                )),
265                text_color: Some(palette.background.weak.text),
266                border: Border {
267                    color: palette.background.strong.color,
268                    width: 1.0,
269                    radius: 6.0.into(),
270                },
271                shadow: Shadow {
272                    color: Color::BLACK.scale_alpha(0.35),
273                    offset: Vector::new(0.0, 4.0),
274                    blur_radius: 14.0,
275                },
276                ..container::Style::default()
277            }
278        })
279        .into()
280}
281
282fn menu_item(
283    label: String,
284    shortcut: String,
285    message: Option<Message>,
286) -> Element<'static, Message> {
287    let enabled = message.is_some();
288    let content = row![
289        text(label).size(13),
290        Space::new().width(Length::Fill),
291        text(shortcut).size(12),
292    ]
293    .align_y(iced::Alignment::Center);
294
295    button(content)
296        .width(Length::Fill)
297        .padding([6, 9])
298        .on_press_maybe(message)
299        .style(move |theme: &Theme, status| {
300            let palette = theme.extended_palette();
301            let text_color = if enabled {
302                palette.background.weak.text
303            } else {
304                palette.background.weak.text.scale_alpha(0.35)
305            };
306            let background = matches!(
307                status,
308                button::Status::Hovered | button::Status::Pressed
309            )
310            .then_some(Background::Color(palette.background.strong.color));
311
312            button::Style {
313                background,
314                text_color,
315                border: Border { radius: 4.0.into(), ..Border::default() },
316                ..button::Style::default()
317            }
318        })
319        .into()
320}
321
322fn separator() -> Element<'static, Message> {
323    let line =
324        container(Space::new().width(Length::Fill).height(Length::Fixed(1.0)))
325            .style(|theme: &Theme| {
326                let palette = theme.extended_palette();
327                container::Style {
328                    background: Some(Background::Color(
329                        palette.background.strong.color,
330                    )),
331                    ..container::Style::default()
332                }
333            });
334
335    container(line).padding([3, 7]).into()
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use crate::{ContextMenuEntry, ContextMenuItem, Language, Translations};
342
343    #[test]
344    fn test_custom_context_menu_action_message_preserves_id() {
345        let entries = build_entries(
346            &[ContextMenuEntry::Item(ContextMenuItem::new(
347                "refactor.extract",
348                "Extract function",
349            ))],
350            false,
351            MenuState::default(),
352            &Translations::default(),
353        );
354
355        assert!(matches!(
356            &entries[0],
357            MenuEntry::Item {
358                message: Some(Message::CustomContextMenuAction(id)),
359                ..
360            }
361                if id == "refactor.extract"
362        ));
363    }
364
365    #[test]
366    fn test_custom_entries_precede_default_entries() {
367        let entries = build_entries(
368            &[ContextMenuEntry::item("custom.format", "Format document")],
369            true,
370            MenuState::default(),
371            &Translations::default(),
372        );
373
374        assert_eq!(entries[0].label(), Some("Format document"));
375        assert!(matches!(entries[1], MenuEntry::Separator));
376        assert_eq!(entries[2].label(), Some("Undo"));
377    }
378
379    #[test]
380    fn test_context_menu_uses_selected_language() {
381        let translations = Translations::new(Language::ChineseSimplified);
382        let entries = default_entries(MenuState::default(), &translations);
383
384        assert_eq!(entries[0].label(), Some("撤消"));
385        assert_eq!(entries[1].label(), Some("恢复"));
386        assert_eq!(entries[3].label(), Some("剪切"));
387        assert_eq!(entries[4].label(), Some("复制"));
388        assert_eq!(entries[5].label(), Some("粘贴"));
389        assert_eq!(entries[7].label(), Some("选择全部"));
390
391        let custom = custom_entries(&[ContextMenuEntry::item(
392            "custom.format",
393            "Format document",
394        )]);
395        assert_eq!(custom[0].label(), Some("Format document"));
396    }
397
398    #[test]
399    fn test_reveal_in_file_manager_entry_emits_request() {
400        let translations = Translations::new(Language::English);
401        let entries = build_entries(
402            &[],
403            true,
404            MenuState {
405                reveal_in_file_manager_enabled: true,
406                ..MenuState::default()
407            },
408            &translations,
409        );
410
411        assert!(matches!(
412            &entries[0],
413            MenuEntry::Item { label, shortcut, message }
414                if label == &translations.context_menu_reveal_in_file_manager()
415                    && shortcut.is_empty()
416                    && matches!(message, Some(Message::RevealInFileManager))
417        ));
418        assert!(matches!(entries[1], MenuEntry::Separator));
419        assert_eq!(entries[2].label(), Some("Undo"));
420    }
421
422    #[test]
423    fn test_reveal_in_file_manager_respects_default_menu_toggle() {
424        let entries = build_entries(
425            &[],
426            false,
427            MenuState {
428                reveal_in_file_manager_enabled: true,
429                ..MenuState::default()
430            },
431            &Translations::default(),
432        );
433
434        assert!(entries.is_empty());
435    }
436}