1use 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#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct ContextMenuItem {
14 pub id: String,
16 pub label: String,
18 pub shortcut: Option<String>,
20 pub enabled: bool,
22}
23
24impl ContextMenuItem {
25 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 #[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 #[must_use]
44 pub fn with_enabled(mut self, enabled: bool) -> Self {
45 self.enabled = enabled;
46 self
47 }
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum ContextMenuEntry {
53 Item(ContextMenuItem),
55 Separator,
57}
58
59impl ContextMenuEntry {
60 pub fn item(id: impl Into<String>, label: impl Into<String>) -> Self {
62 Self::Item(ContextMenuItem::new(id, label))
63 }
64
65 pub const fn separator() -> Self {
67 Self::Separator
68 }
69
70 #[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 #[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
235pub(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}