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