Skip to main content

kimun_notes/keys/
action_shortcuts.rs

1use std::fmt::Display;
2
3use serde::{Deserialize, Serialize};
4
5/// Groups an [`ActionShortcuts`] variant for display in the help modal.
6/// The `Ord` order determines the section render order.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
8pub enum ShortcutCategory {
9    Navigation,
10    Notes,
11    TextEditing,
12    Other,
13}
14
15impl Display for ShortcutCategory {
16    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17        match self {
18            ShortcutCategory::Navigation => write!(f, "Navigation"),
19            ShortcutCategory::Notes => write!(f, "Notes"),
20            ShortcutCategory::TextEditing => write!(f, "Text Editing"),
21            ShortcutCategory::Other => write!(f, "Other"),
22        }
23    }
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
27#[serde(try_from = "String", into = "String")]
28pub enum ActionShortcuts {
29    Quit,
30    OpenPreferences,
31    SearchNotes,
32    OpenNote,
33    NewJournal,
34    Text(TextAction),
35    // TUI navigation / file list
36    ToggleSidebar,
37    OpenFileBrowser,
38    FocusEditor,
39    FocusSidebar,
40    OpenSortDialog,
41    // File operations
42    FileOperations,
43    // Editor link navigation
44    FollowLink,
45    // Quick capture
46    QuickNote,
47    // Query panel
48    ToggleQueryPanel,
49    OpenSavedSearches,
50    SaveCurrentQuery,
51    // Workspace
52    SwitchWorkspace,
53    // In-buffer find (Ctrl+F by default; reopens / advances to next match if
54    // already open).
55    FindInBuffer,
56    /// The leader gateway (Ctrl+G by default): starts a key sequence against
57    /// the leader tree in every context, including mid-typing.
58    Leader,
59    /// The command palette (Ctrl+Shift+P by default): every leader command
60    /// as a fuzzy list.
61    OpenCommandPalette,
62}
63
64impl ActionShortcuts {
65    pub fn category(&self) -> ShortcutCategory {
66        match self {
67            ActionShortcuts::Leader
68            | ActionShortcuts::OpenCommandPalette
69            | ActionShortcuts::ToggleSidebar
70            | ActionShortcuts::OpenFileBrowser
71            | ActionShortcuts::FocusSidebar
72            | ActionShortcuts::FocusEditor
73            | ActionShortcuts::OpenSortDialog
74            | ActionShortcuts::ToggleQueryPanel
75            | ActionShortcuts::OpenSavedSearches
76            | ActionShortcuts::SaveCurrentQuery
77            | ActionShortcuts::SwitchWorkspace => ShortcutCategory::Navigation,
78
79            ActionShortcuts::SearchNotes
80            | ActionShortcuts::OpenNote
81            | ActionShortcuts::NewJournal
82            | ActionShortcuts::FileOperations
83            | ActionShortcuts::FollowLink
84            | ActionShortcuts::QuickNote
85            | ActionShortcuts::FindInBuffer => ShortcutCategory::Notes,
86
87            ActionShortcuts::Text(_) => ShortcutCategory::TextEditing,
88
89            ActionShortcuts::Quit | ActionShortcuts::OpenPreferences => ShortcutCategory::Other,
90        }
91    }
92
93    pub fn label(&self) -> String {
94        match self {
95            ActionShortcuts::Quit => "Quit".into(),
96            ActionShortcuts::OpenPreferences => "Preferences".into(),
97            ActionShortcuts::SearchNotes => "Search notes".into(),
98            ActionShortcuts::OpenNote => "Open note".into(),
99            ActionShortcuts::NewJournal => "New journal entry".into(),
100            ActionShortcuts::ToggleSidebar => "Toggle drawer".into(),
101            ActionShortcuts::OpenFileBrowser => "Open file browser".into(),
102            ActionShortcuts::FocusEditor => "Focus right".into(),
103            ActionShortcuts::FocusSidebar => "Focus left".into(),
104            ActionShortcuts::OpenSortDialog => "Sort options".into(),
105            ActionShortcuts::FileOperations => "File operations".into(),
106            ActionShortcuts::FollowLink => "Follow link".into(),
107            ActionShortcuts::QuickNote => "Quick note".into(),
108            ActionShortcuts::ToggleQueryPanel => "Toggle query drawer".into(),
109            ActionShortcuts::OpenSavedSearches => "Saved searches".into(),
110            ActionShortcuts::SaveCurrentQuery => "Save current query".into(),
111            ActionShortcuts::SwitchWorkspace => "Switch workspace".into(),
112            ActionShortcuts::FindInBuffer => "Find in note".into(),
113            ActionShortcuts::Leader => "Leader menu".into(),
114            ActionShortcuts::OpenCommandPalette => "Command palette".into(),
115            ActionShortcuts::Text(ta) => match ta {
116                TextAction::Bold => "Bold".into(),
117                TextAction::Italic => "Italic".into(),
118                TextAction::Link => "Insert link".into(),
119                TextAction::Image => "Insert image".into(),
120                TextAction::ToggleHeader => "Toggle header".into(),
121                TextAction::Header(n) => format!("Header {n}"),
122                TextAction::Underline => "Underline".into(),
123                TextAction::Strikethrough => "Strikethrough".into(),
124            },
125        }
126    }
127}
128
129impl Display for ActionShortcuts {
130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131        let action = match self {
132            ActionShortcuts::Quit => "Quit".to_string(),
133            ActionShortcuts::OpenPreferences => "OpenSettings".to_string(),
134            ActionShortcuts::SearchNotes => "SearchNotes".to_string(),
135            ActionShortcuts::OpenNote => "OpenNote".to_string(),
136            ActionShortcuts::NewJournal => "NewJournal".to_string(),
137            ActionShortcuts::Text(text_action) => format!("TextEditor-{}", text_action),
138            ActionShortcuts::ToggleSidebar => "ToggleSidebar".to_string(),
139            ActionShortcuts::OpenFileBrowser => "OpenFileBrowser".to_string(),
140            ActionShortcuts::FocusEditor => "FocusEditor".to_string(),
141            ActionShortcuts::FocusSidebar => "FocusSidebar".to_string(),
142            ActionShortcuts::OpenSortDialog => "OpenSortDialog".to_string(),
143            ActionShortcuts::FileOperations => "FileOperations".to_string(),
144            ActionShortcuts::FollowLink => "FollowLink".to_string(),
145            ActionShortcuts::QuickNote => "QuickNote".to_string(),
146            ActionShortcuts::ToggleQueryPanel => "ToggleQueryPanel".to_string(),
147            ActionShortcuts::OpenSavedSearches => "OpenSavedSearches".to_string(),
148            ActionShortcuts::SaveCurrentQuery => "SaveCurrentQuery".to_string(),
149            ActionShortcuts::SwitchWorkspace => "SwitchWorkspace".to_string(),
150            ActionShortcuts::FindInBuffer => "FindInBuffer".to_string(),
151            ActionShortcuts::Leader => "Leader".to_string(),
152            ActionShortcuts::OpenCommandPalette => "OpenCommandPalette".to_string(),
153        };
154        write!(f, "{}", action)
155    }
156}
157
158impl TryFrom<String> for ActionShortcuts {
159    type Error = String;
160
161    fn try_from(value: String) -> Result<Self, Self::Error> {
162        let action = match value.as_str() {
163            "Quit" => ActionShortcuts::Quit,
164            // "OpenSettings" is the stable on-disk name; "OpenPreferences"
165            // accepted as an alias since the screen is named Preferences now.
166            "OpenSettings" | "OpenPreferences" => ActionShortcuts::OpenPreferences,
167            "SearchNotes" => ActionShortcuts::SearchNotes,
168            "OpenNote" => ActionShortcuts::OpenNote,
169            "NewJournal" => ActionShortcuts::NewJournal,
170            "ToggleSidebar" => ActionShortcuts::ToggleSidebar,
171            "OpenFileBrowser" => ActionShortcuts::OpenFileBrowser,
172            "FocusEditor" => ActionShortcuts::FocusEditor,
173            "FocusSidebar" => ActionShortcuts::FocusSidebar,
174            "OpenSortDialog" => ActionShortcuts::OpenSortDialog,
175            "CycleSortField" => ActionShortcuts::OpenSortDialog,
176            "SortReverseOrder" => ActionShortcuts::OpenSortDialog,
177            "FileOperations" => ActionShortcuts::FileOperations,
178            "FollowLink" => ActionShortcuts::FollowLink,
179            "QuickNote" => ActionShortcuts::QuickNote,
180            "ToggleQueryPanel" => ActionShortcuts::ToggleQueryPanel,
181            "ToggleBacklinks" => ActionShortcuts::ToggleQueryPanel,
182            "OpenSavedSearches" => ActionShortcuts::OpenSavedSearches,
183            "SaveCurrentQuery" => ActionShortcuts::SaveCurrentQuery,
184            "SwitchWorkspace" => ActionShortcuts::SwitchWorkspace,
185            "FindInBuffer" => ActionShortcuts::FindInBuffer,
186            "Leader" => ActionShortcuts::Leader,
187            "OpenCommandPalette" => ActionShortcuts::OpenCommandPalette,
188            _ => {
189                if let Some(text_action) = value.strip_prefix("TextEditor-") {
190                    match TextAction::try_from(text_action.to_string()) {
191                        Ok(ta) => ActionShortcuts::Text(ta),
192                        Err(e) => return Err(format!("Error extracting Text Action: {}", e)),
193                    }
194                } else {
195                    return Err(format!("Error, non valid Action: {}", value));
196                }
197            }
198        };
199        Ok(action)
200    }
201}
202
203impl From<ActionShortcuts> for String {
204    fn from(value: ActionShortcuts) -> Self {
205        value.to_string()
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn shortcut_category_order() {
215        assert!(ShortcutCategory::Navigation < ShortcutCategory::Notes);
216        assert!(ShortcutCategory::Notes < ShortcutCategory::TextEditing);
217        assert!(ShortcutCategory::TextEditing < ShortcutCategory::Other);
218    }
219
220    #[test]
221    fn shortcut_category_display() {
222        assert_eq!(ShortcutCategory::Navigation.to_string(), "Navigation");
223        assert_eq!(ShortcutCategory::Notes.to_string(), "Notes");
224        assert_eq!(ShortcutCategory::TextEditing.to_string(), "Text Editing");
225        assert_eq!(ShortcutCategory::Other.to_string(), "Other");
226    }
227
228    #[test]
229    fn action_shortcuts_categories() {
230        assert_eq!(
231            ActionShortcuts::ToggleSidebar.category(),
232            ShortcutCategory::Navigation
233        );
234        assert_eq!(
235            ActionShortcuts::FocusSidebar.category(),
236            ShortcutCategory::Navigation
237        );
238        assert_eq!(
239            ActionShortcuts::FocusEditor.category(),
240            ShortcutCategory::Navigation
241        );
242        assert_eq!(
243            ActionShortcuts::OpenSortDialog.category(),
244            ShortcutCategory::Navigation
245        );
246        assert_eq!(
247            ActionShortcuts::ToggleQueryPanel.category(),
248            ShortcutCategory::Navigation
249        );
250        assert_eq!(
251            ActionShortcuts::OpenSavedSearches.category(),
252            ShortcutCategory::Navigation
253        );
254        assert_eq!(
255            ActionShortcuts::SaveCurrentQuery.category(),
256            ShortcutCategory::Navigation
257        );
258        assert_eq!(
259            ActionShortcuts::SwitchWorkspace.category(),
260            ShortcutCategory::Navigation
261        );
262
263        assert_eq!(
264            ActionShortcuts::SearchNotes.category(),
265            ShortcutCategory::Notes
266        );
267        assert_eq!(
268            ActionShortcuts::OpenNote.category(),
269            ShortcutCategory::Notes
270        );
271        assert_eq!(
272            ActionShortcuts::NewJournal.category(),
273            ShortcutCategory::Notes
274        );
275        assert_eq!(
276            ActionShortcuts::FileOperations.category(),
277            ShortcutCategory::Notes
278        );
279        assert_eq!(
280            ActionShortcuts::FollowLink.category(),
281            ShortcutCategory::Notes
282        );
283        assert_eq!(
284            ActionShortcuts::QuickNote.category(),
285            ShortcutCategory::Notes
286        );
287        assert_eq!(
288            ActionShortcuts::FindInBuffer.category(),
289            ShortcutCategory::Notes
290        );
291
292        assert_eq!(
293            ActionShortcuts::Text(TextAction::Bold).category(),
294            ShortcutCategory::TextEditing
295        );
296        assert_eq!(
297            ActionShortcuts::Text(TextAction::Header(2)).category(),
298            ShortcutCategory::TextEditing
299        );
300
301        assert_eq!(ActionShortcuts::Quit.category(), ShortcutCategory::Other);
302        assert_eq!(
303            ActionShortcuts::OpenPreferences.category(),
304            ShortcutCategory::Other
305        );
306    }
307
308    #[test]
309    fn action_shortcuts_labels() {
310        assert_eq!(ActionShortcuts::Quit.label(), "Quit");
311        assert_eq!(ActionShortcuts::OpenPreferences.label(), "Preferences");
312        assert_eq!(ActionShortcuts::SearchNotes.label(), "Search notes");
313        assert_eq!(ActionShortcuts::OpenNote.label(), "Open note");
314        assert_eq!(ActionShortcuts::NewJournal.label(), "New journal entry");
315        assert_eq!(ActionShortcuts::ToggleSidebar.label(), "Toggle drawer");
316        assert_eq!(
317            ActionShortcuts::OpenFileBrowser.label(),
318            "Open file browser"
319        );
320        assert_eq!(ActionShortcuts::FocusEditor.label(), "Focus right");
321        assert_eq!(ActionShortcuts::FocusSidebar.label(), "Focus left");
322        assert_eq!(ActionShortcuts::OpenSortDialog.label(), "Sort options");
323        assert_eq!(ActionShortcuts::FileOperations.label(), "File operations");
324        assert_eq!(ActionShortcuts::FollowLink.label(), "Follow link");
325        assert_eq!(ActionShortcuts::QuickNote.label(), "Quick note");
326        assert_eq!(
327            ActionShortcuts::ToggleQueryPanel.label(),
328            "Toggle query drawer"
329        );
330        assert_eq!(ActionShortcuts::OpenSavedSearches.label(), "Saved searches");
331        assert_eq!(
332            ActionShortcuts::SaveCurrentQuery.label(),
333            "Save current query"
334        );
335        assert_eq!(ActionShortcuts::SwitchWorkspace.label(), "Switch workspace");
336        assert_eq!(ActionShortcuts::FindInBuffer.label(), "Find in note");
337        assert_eq!(ActionShortcuts::Text(TextAction::Bold).label(), "Bold");
338        assert_eq!(ActionShortcuts::Text(TextAction::Italic).label(), "Italic");
339        assert_eq!(
340            ActionShortcuts::Text(TextAction::Link).label(),
341            "Insert link"
342        );
343        assert_eq!(
344            ActionShortcuts::Text(TextAction::Image).label(),
345            "Insert image"
346        );
347        assert_eq!(
348            ActionShortcuts::Text(TextAction::ToggleHeader).label(),
349            "Toggle header"
350        );
351        assert_eq!(
352            ActionShortcuts::Text(TextAction::Header(1)).label(),
353            "Header 1"
354        );
355        assert_eq!(
356            ActionShortcuts::Text(TextAction::Header(2)).label(),
357            "Header 2"
358        );
359        assert_eq!(
360            ActionShortcuts::Text(TextAction::Underline).label(),
361            "Underline"
362        );
363        assert_eq!(
364            ActionShortcuts::Text(TextAction::Strikethrough).label(),
365            "Strikethrough"
366        );
367    }
368
369    #[test]
370    fn file_operations_roundtrip() {
371        assert_eq!(
372            ActionShortcuts::FileOperations.to_string(),
373            "FileOperations"
374        );
375        assert_eq!(
376            ActionShortcuts::try_from("FileOperations".to_string()),
377            Ok(ActionShortcuts::FileOperations)
378        );
379    }
380
381    #[test]
382    fn open_file_browser_roundtrip() {
383        assert_eq!(
384            ActionShortcuts::OpenFileBrowser.to_string(),
385            "OpenFileBrowser"
386        );
387        assert_eq!(
388            ActionShortcuts::try_from("OpenFileBrowser".to_string()),
389            Ok(ActionShortcuts::OpenFileBrowser)
390        );
391        assert_eq!(
392            ActionShortcuts::OpenFileBrowser.category(),
393            ShortcutCategory::Navigation
394        );
395    }
396
397    #[test]
398    fn saved_search_actions_roundtrip() {
399        assert_eq!(
400            ActionShortcuts::ToggleQueryPanel.to_string(),
401            "ToggleQueryPanel"
402        );
403        assert_eq!(
404            ActionShortcuts::try_from("ToggleQueryPanel".to_string()),
405            Ok(ActionShortcuts::ToggleQueryPanel)
406        );
407        // legacy alias still parses to the renamed action
408        assert_eq!(
409            ActionShortcuts::try_from("ToggleBacklinks".to_string()),
410            Ok(ActionShortcuts::ToggleQueryPanel)
411        );
412        assert_eq!(
413            ActionShortcuts::try_from("OpenSavedSearches".to_string()),
414            Ok(ActionShortcuts::OpenSavedSearches)
415        );
416        assert_eq!(
417            ActionShortcuts::try_from("SaveCurrentQuery".to_string()),
418            Ok(ActionShortcuts::SaveCurrentQuery)
419        );
420    }
421
422    #[test]
423    fn open_sort_dialog_roundtrip_and_legacy_alias() {
424        assert_eq!(
425            ActionShortcuts::OpenSortDialog.to_string(),
426            "OpenSortDialog"
427        );
428        assert_eq!(
429            ActionShortcuts::try_from("OpenSortDialog".to_string()),
430            Ok(ActionShortcuts::OpenSortDialog)
431        );
432        assert_eq!(
433            ActionShortcuts::try_from("CycleSortField".to_string()),
434            Ok(ActionShortcuts::OpenSortDialog)
435        );
436        assert_eq!(
437            ActionShortcuts::try_from("SortReverseOrder".to_string()),
438            Ok(ActionShortcuts::OpenSortDialog)
439        );
440    }
441}
442
443#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
444pub enum TextAction {
445    Bold,
446    Italic,
447    Link,
448    Image,
449    ToggleHeader,
450    Header(u8),
451    Underline,
452    Strikethrough,
453}
454
455impl Display for TextAction {
456    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
457        let name = match self {
458            TextAction::Bold => "Bold".to_string(),
459            TextAction::Italic => "Italic".to_string(),
460            TextAction::Link => "Link".to_string(),
461            TextAction::Image => "Image".to_string(),
462            TextAction::ToggleHeader => "ToggleHeader".to_string(),
463            TextAction::Header(level) => format!("Header{}", level),
464            TextAction::Underline => "Underline".to_string(),
465            TextAction::Strikethrough => "Strikethrough".to_string(),
466        };
467        write!(f, "{}", name)
468    }
469}
470
471impl TryFrom<String> for TextAction {
472    type Error = String;
473
474    fn try_from(value: String) -> Result<Self, Self::Error> {
475        let action = match value.as_str() {
476            "Bold" => TextAction::Bold,
477            "Italic" => TextAction::Italic,
478            "Link" => TextAction::Link,
479            "Image" => TextAction::Image,
480            "ToggleHeader" => TextAction::ToggleHeader,
481            "Underline" => TextAction::Underline,
482            "Strikethrough" => TextAction::Strikethrough,
483            _ => {
484                if let Some(level) = value.strip_prefix("Header") {
485                    match level.parse::<u8>() {
486                        Ok(lvl) => TextAction::Header(lvl),
487                        Err(e) => return Err(format!("Error parsing header level: {}", e)),
488                    }
489                } else {
490                    return Err(format!("Error, not valid Text Action: {}", value));
491                }
492            }
493        };
494        Ok(action)
495    }
496}