use std::fmt; use serde::{Deserialize, Deserializer, Serialize, Serializer}; /// All possible actions that can be triggered by a keybinding. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum Action { // Cursor movement CursorLeft, CursorRight, CursorWordLeft, CursorWordRight, CursorWordEnd, CursorStart, CursorEnd, // Editing DeleteCharBefore, DeleteCharAfter, DeleteWordBefore, DeleteWordAfter, DeleteToWordBoundary, ClearLine, ClearToStart, ClearToEnd, // List navigation SelectNext, SelectPrevious, ScrollHalfPageUp, ScrollHalfPageDown, ScrollPageUp, ScrollPageDown, ScrollToTop, ScrollToBottom, ScrollToScreenTop, ScrollToScreenMiddle, ScrollToScreenBottom, // Commands — accept selection and execute immediately Accept, AcceptNth(u8), // Commands — return selection to command line without executing ReturnSelection, ReturnSelectionNth(u8), // Commands — other Copy, Delete, DeleteAll, ReturnOriginal, ReturnQuery, Exit, Redraw, CycleFilterMode, CycleSearchMode, SwitchContext, ClearContext, ToggleTab, // Mode changes VimEnterNormal, VimEnterInsert, VimEnterInsertAfter, VimEnterInsertAtStart, VimEnterInsertAtEnd, VimSearchInsert, VimChangeToEnd, EnterPrefixMode, // Inspector InspectPrevious, InspectNext, // Special Noop, } impl Action { /// Convert from a kebab-case string. pub(crate) fn from_str(s: &str) -> Result { // Handle accept-N and return-selection-N patterns if let Some(rest) = s.strip_prefix("accept-") && let Ok(n) = rest.parse::() && (1..=9).contains(&n) { return Ok(Self::AcceptNth(n)); } if let Some(rest) = s.strip_prefix("return-selection-") && let Ok(n) = rest.parse::() && (1..=9).contains(&n) { return Ok(Self::ReturnSelectionNth(n)); } match s { "cursor-left" => Ok(Self::CursorLeft), "cursor-right" => Ok(Self::CursorRight), "cursor-word-left" => Ok(Self::CursorWordLeft), "cursor-word-right" => Ok(Self::CursorWordRight), "cursor-word-end" => Ok(Self::CursorWordEnd), "cursor-start" => Ok(Self::CursorStart), "cursor-end" => Ok(Self::CursorEnd), "delete-char-before" => Ok(Self::DeleteCharBefore), "delete-char-after" => Ok(Self::DeleteCharAfter), "delete-word-before" => Ok(Self::DeleteWordBefore), "delete-word-after" => Ok(Self::DeleteWordAfter), "delete-to-word-boundary" => Ok(Self::DeleteToWordBoundary), "clear-line" => Ok(Self::ClearLine), "clear-to-start" => Ok(Self::ClearToStart), "clear-to-end" => Ok(Self::ClearToEnd), "select-next" => Ok(Self::SelectNext), "select-previous" => Ok(Self::SelectPrevious), "scroll-half-page-up" => Ok(Self::ScrollHalfPageUp), "scroll-half-page-down" => Ok(Self::ScrollHalfPageDown), "scroll-page-up" => Ok(Self::ScrollPageUp), "scroll-page-down" => Ok(Self::ScrollPageDown), "scroll-to-top" => Ok(Self::ScrollToTop), "scroll-to-bottom" => Ok(Self::ScrollToBottom), "scroll-to-screen-top" => Ok(Self::ScrollToScreenTop), "scroll-to-screen-middle" => Ok(Self::ScrollToScreenMiddle), "scroll-to-screen-bottom" => Ok(Self::ScrollToScreenBottom), "accept" => Ok(Self::Accept), "return-selection" => Ok(Self::ReturnSelection), "copy" => Ok(Self::Copy), "delete" => Ok(Self::Delete), "delete-all" => Ok(Self::DeleteAll), "return-original" => Ok(Self::ReturnOriginal), "return-query" => Ok(Self::ReturnQuery), "exit" => Ok(Self::Exit), "redraw" => Ok(Self::Redraw), "cycle-filter-mode" => Ok(Self::CycleFilterMode), "cycle-search-mode" => Ok(Self::CycleSearchMode), "switch-context" => Ok(Self::SwitchContext), "clear-context" => Ok(Self::ClearContext), "toggle-tab" => Ok(Self::ToggleTab), "vim-enter-normal" => Ok(Self::VimEnterNormal), "vim-enter-insert" => Ok(Self::VimEnterInsert), "vim-enter-insert-after" => Ok(Self::VimEnterInsertAfter), "vim-enter-insert-at-start" => Ok(Self::VimEnterInsertAtStart), "vim-enter-insert-at-end" => Ok(Self::VimEnterInsertAtEnd), "vim-search-insert" => Ok(Self::VimSearchInsert), "vim-change-to-end" => Ok(Self::VimChangeToEnd), "enter-prefix-mode" => Ok(Self::EnterPrefixMode), "inspect-previous" => Ok(Self::InspectPrevious), "inspect-next" => Ok(Self::InspectNext), "noop" => Ok(Self::Noop), _ => Err(format!("unknown action: {s}")), } } /// Convert to a kebab-case string. pub(crate) fn as_str(&self) -> String { match self { Self::CursorLeft => "cursor-left".to_string(), Self::CursorRight => "cursor-right".to_string(), Self::CursorWordLeft => "cursor-word-left".to_string(), Self::CursorWordRight => "cursor-word-right".to_string(), Self::CursorWordEnd => "cursor-word-end".to_string(), Self::CursorStart => "cursor-start".to_string(), Self::CursorEnd => "cursor-end".to_string(), Self::DeleteCharBefore => "delete-char-before".to_string(), Self::DeleteCharAfter => "delete-char-after".to_string(), Self::DeleteWordBefore => "delete-word-before".to_string(), Self::DeleteWordAfter => "delete-word-after".to_string(), Self::DeleteToWordBoundary => "delete-to-word-boundary".to_string(), Self::ClearLine => "clear-line".to_string(), Self::ClearToStart => "clear-to-start".to_string(), Self::ClearToEnd => "clear-to-end".to_string(), Self::SelectNext => "select-next".to_string(), Self::SelectPrevious => "select-previous".to_string(), Self::ScrollHalfPageUp => "scroll-half-page-up".to_string(), Self::ScrollHalfPageDown => "scroll-half-page-down".to_string(), Self::ScrollPageUp => "scroll-page-up".to_string(), Self::ScrollPageDown => "scroll-page-down".to_string(), Self::ScrollToTop => "scroll-to-top".to_string(), Self::ScrollToBottom => "scroll-to-bottom".to_string(), Self::ScrollToScreenTop => "scroll-to-screen-top".to_string(), Self::ScrollToScreenMiddle => "scroll-to-screen-middle".to_string(), Self::ScrollToScreenBottom => "scroll-to-screen-bottom".to_string(), Self::Accept => "accept".to_string(), Self::AcceptNth(n) => format!("accept-{n}"), Self::ReturnSelection => "return-selection".to_string(), Self::ReturnSelectionNth(n) => format!("return-selection-{n}"), Self::Copy => "copy".to_string(), Self::Delete => "delete".to_string(), Self::DeleteAll => "delete-all".to_string(), Self::ReturnOriginal => "return-original".to_string(), Self::ReturnQuery => "return-query".to_string(), Self::Exit => "exit".to_string(), Self::Redraw => "redraw".to_string(), Self::CycleFilterMode => "cycle-filter-mode".to_string(), Self::CycleSearchMode => "cycle-search-mode".to_string(), Self::SwitchContext => "switch-context".to_string(), Self::ClearContext => "clear-context".to_string(), Self::ToggleTab => "toggle-tab".to_string(), Self::VimEnterNormal => "vim-enter-normal".to_string(), Self::VimEnterInsert => "vim-enter-insert".to_string(), Self::VimEnterInsertAfter => "vim-enter-insert-after".to_string(), Self::VimEnterInsertAtStart => "vim-enter-insert-at-start".to_string(), Self::VimEnterInsertAtEnd => "vim-enter-insert-at-end".to_string(), Self::VimSearchInsert => "vim-search-insert".to_string(), Self::VimChangeToEnd => "vim-change-to-end".to_string(), Self::EnterPrefixMode => "enter-prefix-mode".to_string(), Self::InspectPrevious => "inspect-previous".to_string(), Self::InspectNext => "inspect-next".to_string(), Self::Noop => "noop".to_string(), } } } impl fmt::Display for Action { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.as_str()) } } impl Serialize for Action { fn serialize(&self, serializer: S) -> Result { serializer.serialize_str(&self.as_str()) } } impl<'de> Deserialize<'de> for Action { fn deserialize>(deserializer: D) -> Result { let s = String::deserialize(deserializer)?; Self::from_str(&s).map_err(serde::de::Error::custom) } } #[cfg(test)] mod tests { use super::Action; #[test] fn parse_basic_actions() { assert_eq!(Action::from_str("cursor-left").unwrap(), Action::CursorLeft); assert_eq!(Action::from_str("accept").unwrap(), Action::Accept); assert_eq!(Action::from_str("exit").unwrap(), Action::Exit); assert_eq!(Action::from_str("noop").unwrap(), Action::Noop); assert_eq!( Action::from_str("vim-enter-normal").unwrap(), Action::VimEnterNormal ); } #[test] fn parse_accept_nth() { assert_eq!(Action::from_str("accept-1").unwrap(), Action::AcceptNth(1)); assert_eq!(Action::from_str("accept-9").unwrap(), Action::AcceptNth(9)); } #[test] fn parse_return_selection() { assert_eq!( Action::from_str("return-selection").unwrap(), Action::ReturnSelection ); assert_eq!( Action::from_str("return-selection-1").unwrap(), Action::ReturnSelectionNth(1) ); assert_eq!( Action::from_str("return-selection-9").unwrap(), Action::ReturnSelectionNth(9) ); } #[test] fn parse_unknown_action() { assert!(Action::from_str("unknown-action").is_err()); assert!(Action::from_str("accept-0").is_err()); assert!(Action::from_str("accept-10").is_err()); assert!(Action::from_str("return-selection-0").is_err()); assert!(Action::from_str("return-selection-10").is_err()); } #[test] fn round_trip() { let actions = vec![ Action::CursorLeft, Action::Accept, Action::AcceptNth(5), Action::ReturnSelection, Action::ReturnSelectionNth(3), Action::VimSearchInsert, Action::ScrollToScreenMiddle, ]; for action in actions { let s = action.as_str(); let parsed = Action::from_str(&s).unwrap(); assert_eq!(action, parsed); } } #[test] fn serde_round_trip() { let action = Action::CursorLeft; let json = serde_json::to_string(&action).unwrap(); assert_eq!(json, "\"cursor-left\""); let parsed: Action = serde_json::from_str(&json).unwrap(); assert_eq!(parsed, Action::CursorLeft); let action = Action::AcceptNth(3); let json = serde_json::to_string(&action).unwrap(); assert_eq!(json, "\"accept-3\""); let parsed: Action = serde_json::from_str(&json).unwrap(); assert_eq!(parsed, Action::AcceptNth(3)); } }