use derive_more::Deref;
use indexmap::IndexMap;
use itertools::Itertools;
use serde::{
Deserialize, Serialize,
de::{self, value::StringDeserializer},
};
use slumber_util::{
Mapping, NEW_ISSUE_LINK,
yaml::{
self, DeserializeYaml, Expected, LocatedError, SourceMap, SourcedYaml,
},
};
use std::{
borrow::Cow,
fmt::{self, Debug, Display},
iter,
str::FromStr,
};
use terminput::{KeyCode, KeyEvent, KeyModifiers, MediaKeyCode};
use thiserror::Error;
const KEY_CODES: Mapping<'static, KeyCode> = Mapping::new(&[
(KeyCode::Esc, &["escape", "esc"]),
(KeyCode::Enter, &["enter"]),
(KeyCode::Left, &["left"]),
(KeyCode::Right, &["right"]),
(KeyCode::Up, &["up"]),
(KeyCode::Down, &["down"]),
(KeyCode::Home, &["home"]),
(KeyCode::End, &["end"]),
(KeyCode::PageUp, &["pageup", "pgup"]),
(KeyCode::PageDown, &["pagedown", "pgdn"]),
(KeyCode::Tab, &["tab"]),
(KeyCode::Backspace, &["backspace"]),
(KeyCode::Delete, &["delete", "del"]),
(KeyCode::Insert, &["insert", "ins"]),
(KeyCode::CapsLock, &["capslock", "caps"]),
(KeyCode::ScrollLock, &["scrolllock"]),
(KeyCode::NumLock, &["numlock"]),
(KeyCode::PrintScreen, &["printscreen"]),
(KeyCode::Pause, &["pausebreak"]),
(KeyCode::Menu, &["menu"]),
(KeyCode::KeypadBegin, &["keypadbegin"]),
(KeyCode::F(1), &["f1"]),
(KeyCode::F(2), &["f2"]),
(KeyCode::F(3), &["f3"]),
(KeyCode::F(4), &["f4"]),
(KeyCode::F(5), &["f5"]),
(KeyCode::F(6), &["f6"]),
(KeyCode::F(7), &["f7"]),
(KeyCode::F(8), &["f8"]),
(KeyCode::F(9), &["f9"]),
(KeyCode::F(10), &["f10"]),
(KeyCode::F(11), &["f11"]),
(KeyCode::F(12), &["f12"]),
(KeyCode::Char(' '), &["space"]),
(KeyCode::Media(MediaKeyCode::Play), &["play"]),
(KeyCode::Media(MediaKeyCode::Pause), &["pause"]),
(KeyCode::Media(MediaKeyCode::PlayPause), &["playpause"]),
(KeyCode::Media(MediaKeyCode::Reverse), &["reverse"]),
(KeyCode::Media(MediaKeyCode::Stop), &["stop"]),
(KeyCode::Media(MediaKeyCode::FastForward), &["fastforward"]),
(KeyCode::Media(MediaKeyCode::Rewind), &["rewind"]),
(KeyCode::Media(MediaKeyCode::TrackNext), &["tracknext"]),
(
KeyCode::Media(MediaKeyCode::TrackPrevious),
&["trackprevious"],
),
(KeyCode::Media(MediaKeyCode::Record), &["record"]),
(KeyCode::Media(MediaKeyCode::LowerVolume), &["lowervolume"]),
(KeyCode::Media(MediaKeyCode::RaiseVolume), &["raisevolume"]),
(KeyCode::Media(MediaKeyCode::MuteVolume), &["mute"]),
]);
const KEY_MODIFIERS: Mapping<'static, KeyModifiers> = Mapping::new(&[
(KeyModifiers::SHIFT, &["shift"]),
(KeyModifiers::ALT, &["alt"]),
(KeyModifiers::CTRL, &["ctrl"]),
(KeyModifiers::SUPER, &["super"]),
(KeyModifiers::HYPER, &["hyper"]),
(KeyModifiers::META, &["meta"]),
]);
#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum Action {
ScrollUp,
ScrollDown,
ScrollLeft,
ScrollRight,
Quit,
ForceQuit,
PreviousPane,
NextPane,
Up,
Down,
Left,
Right,
PageUp,
PageDown,
Home,
End,
Submit,
Toggle,
Cancel,
Delete,
Edit,
Reset,
View,
History,
Search,
Export,
ReloadCollection,
Fullscreen,
OpenActions,
#[serde(alias = "open_help")] Help,
CommandHistory,
SelectCollection,
ToggleSidebar,
#[serde(alias = "select_profile_list")] ProfileList,
#[serde(alias = "select_recipe_list")] RecipeList,
#[serde(alias = "select_recipe")] TopPane,
#[serde(alias = "select_request", alias = "select_response")]
BottomPane,
}
impl Display for Action {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", serde_yaml::to_string(self).unwrap())
}
}
impl DeserializeYaml for Action {
fn expected() -> Expected {
Expected::String
}
fn deserialize(
yaml: SourcedYaml,
_source_map: &SourceMap,
) -> yaml::Result<Self> {
let location = yaml.location;
let s = yaml.try_into_string()?;
<Self as Deserialize>::deserialize(StringDeserializer::new(s)).map_err(
|error: de::value::Error| LocatedError::other(error, location),
)
}
}
#[derive(Clone, Debug, Serialize)]
#[cfg_attr(test, derive(PartialEq))]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(transparent)]
pub struct InputBinding(Vec<KeyCombination>);
impl InputBinding {
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn matches(&self, event: &KeyEvent) -> bool {
self.0.iter().any(|combo| combo.matches(event))
}
}
impl Display for InputBinding {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for (i, combo) in self.0.iter().enumerate() {
if i > 0 {
write!(f, " / ")?;
}
write!(f, "{combo}")?;
}
Ok(())
}
}
impl DeserializeYaml for InputBinding {
fn expected() -> Expected {
Expected::Sequence
}
fn deserialize(
yaml: SourcedYaml,
source_map: &SourceMap,
) -> yaml::Result<Self> {
DeserializeYaml::deserialize(yaml, source_map).map(Self)
}
}
impl From<Vec<(KeyModifiers, KeyCode)>> for InputBinding {
fn from(combos: Vec<(KeyModifiers, KeyCode)>) -> Self {
Self(
combos
.into_iter()
.map(|(modifiers, code)| KeyCombination { modifiers, code })
.collect(),
)
}
}
impl From<(KeyModifiers, KeyCode)> for InputBinding {
fn from((modifiers, code): (KeyModifiers, KeyCode)) -> Self {
Self(vec![KeyCombination { modifiers, code }])
}
}
impl From<Vec<KeyCode>> for InputBinding {
fn from(value: Vec<KeyCode>) -> Self {
Self(value.into_iter().map(KeyCombination::from).collect())
}
}
impl From<KeyCode> for InputBinding {
fn from(code: KeyCode) -> Self {
Self(vec![KeyCombination {
modifiers: KeyModifiers::NONE,
code,
}])
}
}
#[derive(Copy, Clone, Debug, Serialize)]
#[cfg_attr(test, derive(PartialEq))]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(into = "String", try_from = "String")]
pub struct KeyCombination {
pub modifiers: KeyModifiers,
pub code: KeyCode,
}
impl KeyCombination {
const SEPARATOR: char = ' ';
pub fn matches(self, event: &KeyEvent) -> bool {
fn to_lowercase(code: KeyCode) -> KeyCode {
if let KeyCode::Char(c) = code {
KeyCode::Char(c.to_ascii_lowercase())
} else {
code
}
}
to_lowercase(event.code) == to_lowercase(self.code)
&& event.modifiers == self.modifiers
}
}
impl Display for KeyCombination {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
for (name, _) in self.modifiers.iter_names() {
write!(f, "{}{}", name.to_lowercase(), Self::SEPARATOR)?;
}
match self.code {
KeyCode::Tab => write!(f, "tab"),
KeyCode::Up => write!(f, "↑"),
KeyCode::Down => write!(f, "↓"),
KeyCode::Left => write!(f, "←"),
KeyCode::Right => write!(f, "→"),
KeyCode::Esc => write!(f, "esc"),
KeyCode::Enter => write!(f, "enter"),
KeyCode::Delete => write!(f, "del"),
KeyCode::PageUp => write!(f, "pgup"),
KeyCode::PageDown => write!(f, "pgdown"),
KeyCode::Home => write!(f, "home"),
KeyCode::End => write!(f, "end"),
KeyCode::F(num) => write!(f, "F{num}"),
KeyCode::Char(' ') => write!(f, "<space>"),
KeyCode::Char(c) => write!(f, "{c}"),
_ => write!(f, "???"),
}
}
}
impl From<KeyCode> for KeyCombination {
fn from(key_code: KeyCode) -> Self {
Self {
code: key_code,
modifiers: KeyModifiers::NONE,
}
}
}
impl FromStr for KeyCombination {
type Err = InputParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut tokens =
s.trim().split(Self::SEPARATOR).filter(|s| !s.is_empty());
let code = tokens.next_back().ok_or(InputParseError::Empty)?;
let mut modifiers = KeyModifiers::NONE;
let code: KeyCode = if code == "backtab" {
modifiers |= KeyModifiers::SHIFT;
KeyCode::Tab
} else {
parse_key_code(code)?
};
for modifier in tokens {
let modifier = parse_key_modifier(modifier)?;
if modifiers.contains(modifier) {
return Err(InputParseError::DuplicateModifier { modifier });
}
modifiers |= modifier;
}
Ok(Self { modifiers, code })
}
}
impl From<KeyCombination> for String {
fn from(key_combo: KeyCombination) -> Self {
key_combo
.modifiers
.iter()
.map(stringify_key_modifier)
.chain(iter::once(stringify_key_code(key_combo.code)))
.join(" ")
}
}
impl DeserializeYaml for KeyCombination {
fn expected() -> Expected {
Expected::String
}
fn deserialize(
yaml: SourcedYaml,
_source_map: &SourceMap,
) -> yaml::Result<Self> {
let location = yaml.location;
let s = yaml.try_into_string()?;
s.parse()
.map_err(|error| LocatedError::other(error, location))
}
}
#[derive(Clone, Debug, Deref, Serialize)]
#[cfg_attr(test, derive(PartialEq))]
#[serde(transparent)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct InputMap(IndexMap<Action, InputBinding>);
impl InputMap {
fn new(user_bindings: IndexMap<Action, InputBinding>) -> Self {
let mut new = Self::default();
new.0.extend(user_bindings);
new.0.retain(|_, binding| !binding.is_empty());
new
}
pub fn into_inner(self) -> IndexMap<Action, InputBinding> {
self.0
}
}
impl Default for InputMap {
fn default() -> Self {
const CTRL: KeyModifiers = KeyModifiers::CTRL;
const SHIFT: KeyModifiers = KeyModifiers::SHIFT;
Self(IndexMap::from_iter([
(Action::Quit, KeyCode::Char('q').into()),
(Action::ForceQuit, (CTRL, KeyCode::Char('c')).into()),
(
Action::ScrollUp,
vec![(SHIFT, KeyCode::Up), (SHIFT, KeyCode::Char('k'))].into(),
),
(
Action::ScrollDown,
vec![(SHIFT, KeyCode::Down), (SHIFT, KeyCode::Char('j'))]
.into(),
),
(
Action::ScrollLeft,
vec![(SHIFT, KeyCode::Left), (SHIFT, KeyCode::Char('h'))]
.into(),
),
(
Action::ScrollRight,
vec![(SHIFT, KeyCode::Right), (SHIFT, KeyCode::Char('l'))]
.into(),
),
(Action::OpenActions, KeyCode::Char('x').into()),
(Action::Help, KeyCode::Char('?').into()),
(Action::Fullscreen, KeyCode::Char('f').into()),
(Action::ReloadCollection, KeyCode::F(5).into()),
(Action::History, (CTRL, KeyCode::Char('h')).into()),
(Action::Search, KeyCode::Char('/').into()),
(Action::Export, KeyCode::Char(':').into()),
(Action::PreviousPane, (SHIFT, KeyCode::Tab).into()),
(Action::NextPane, KeyCode::Tab.into()),
(Action::Up, vec![KeyCode::Up, KeyCode::Char('k')].into()),
(Action::Down, vec![KeyCode::Down, KeyCode::Char('j')].into()),
(Action::Left, vec![KeyCode::Left, KeyCode::Char('h')].into()),
(
Action::Right,
vec![KeyCode::Right, KeyCode::Char('l')].into(),
),
(Action::PageUp, KeyCode::PageUp.into()),
(Action::PageDown, KeyCode::PageDown.into()),
(Action::Home, KeyCode::Home.into()),
(Action::End, KeyCode::End.into()),
(Action::Submit, KeyCode::Enter.into()),
(Action::Toggle, KeyCode::Char(' ').into()),
(Action::Cancel, KeyCode::Esc.into()),
(Action::Delete, KeyCode::Delete.into()),
(Action::Edit, KeyCode::Char('e').into()),
(Action::Reset, KeyCode::Char('z').into()),
(Action::View, KeyCode::Char('v').into()),
(Action::CommandHistory, (CTRL, KeyCode::Char('r')).into()),
(Action::SelectCollection, KeyCode::F(3).into()),
(Action::ToggleSidebar, KeyCode::Char('s').into()),
(Action::ProfileList, KeyCode::Char('p').into()),
(Action::RecipeList, KeyCode::Char('r').into()),
(Action::TopPane, KeyCode::Char('1').into()),
(Action::BottomPane, KeyCode::Char('2').into()),
]))
}
}
impl DeserializeYaml for InputMap {
fn expected() -> Expected {
Expected::Mapping
}
fn deserialize(
yaml: SourcedYaml,
source_map: &SourceMap,
) -> yaml::Result<Self> {
let user_bindings: IndexMap<Action, InputBinding> =
DeserializeYaml::deserialize(yaml, source_map)?;
Ok(Self::new(user_bindings))
}
}
#[derive(Debug, Error)]
pub enum InputParseError {
#[error("Duplicate modifier {modifier:?}")]
DuplicateModifier { modifier: KeyModifiers },
#[error("Empty key combination")]
Empty,
#[error(
"Invalid key code {input:?}; key combinations should be space-separated"
)]
InvalidKeyCode { input: String },
#[error(
"Invalid key modifier {input:?}; must be one of {:?}",
KEY_MODIFIERS.all_strings().collect_vec(),
)]
InvalidKeyModifier { input: String },
}
fn parse_key_code(s: &str) -> Result<KeyCode, InputParseError> {
if let Ok(c) = s.parse::<char>() {
Ok(KeyCode::Char(c))
} else {
KEY_CODES
.get(s)
.ok_or_else(|| InputParseError::InvalidKeyCode {
input: s.to_owned(),
})
}
}
fn stringify_key_code(code: KeyCode) -> Cow<'static, str> {
if let Some(label) = KEY_CODES.get_label(code) {
label.into()
} else if let KeyCode::Char(c) = code {
c.to_string().into()
} else {
panic!(
"Unmapped key code {code:?}; \
this is a bug, please open an issue: {NEW_ISSUE_LINK}"
)
}
}
fn parse_key_modifier(s: &str) -> Result<KeyModifiers, InputParseError> {
KEY_MODIFIERS
.get(s)
.ok_or_else(|| InputParseError::InvalidKeyModifier {
input: s.to_owned(),
})
}
fn stringify_key_modifier(modifier: KeyModifiers) -> Cow<'static, str> {
KEY_MODIFIERS.get_label(modifier).unwrap().into()
}
#[cfg(test)]
mod tests {
use super::*;
use indexmap::indexmap;
use rstest::rstest;
use slumber_util::{assert_err, yaml::deserialize_yaml};
use terminput::{KeyEventKind, KeyEventState};
#[rstest]
#[case::whitespace_stripped(" w ", KeyCode::Char('w'))]
#[case::f_key("f2", KeyCode::F(2))]
#[case::tab("tab", KeyCode::Tab)]
#[case::page_up("pgup", KeyCode::PageUp)]
#[case::page_down("pgdn", KeyCode::PageDown)]
#[case::caps_lock("capslock", KeyCode::CapsLock)]
#[case::f_key_with_modifier("shift f2", KeyCombination {
code: KeyCode::F(2),
modifiers: KeyModifiers::SHIFT,
})]
#[case::extra_whitespace("shift f2", KeyCombination {
code: KeyCode::F(2),
modifiers: KeyModifiers::SHIFT,
})]
#[case::extra_extra_whitespace("shift f2", KeyCombination {
code: KeyCode::F(2),
modifiers: KeyModifiers::SHIFT,
})]
#[case::all_modifiers("super hyper meta alt ctrl shift f2", KeyCombination {
code: KeyCode::F(2),
modifiers: KeyModifiers::all(),
})]
#[case::backtab("backtab", KeyCombination {
code: KeyCode::Tab,
modifiers: KeyModifiers::SHIFT,
})]
#[case::backtab_modifiers("ctrl backtab", KeyCombination {
code: KeyCode::Tab,
modifiers: KeyModifiers::CTRL | KeyModifiers::SHIFT,
})]
fn test_parse_key_combination(
#[case] input: &str,
#[case] expected: impl Into<KeyCombination>,
) {
assert_eq!(input.parse::<KeyCombination>().unwrap(), expected.into());
}
#[rstest]
#[case::empty("", "Empty key combination")]
#[case::whitespace_only(" ", "Empty key combination")]
#[case::invalid_delimiter("shift+w", "Invalid key code")]
#[case::modifier_last("w shift", "Invalid key code")]
#[case::invalid_modifier("shart w", "Invalid key modifier \"shart\"")]
#[case::modifier_only("shift", "Invalid key code \"shift\"")]
#[case::duplicate_modifier("alt alt w", "Duplicate modifier")]
fn test_parse_key_combination_error(
#[case] input: &str,
#[case] expected_error: &str,
) {
assert_err!(input.parse::<KeyCombination>(), expected_error);
}
#[rstest]
#[case::char_only("g", KeyCode::Char('g'), KeyModifiers::NONE, true)]
#[case::extra_modifier("g", KeyCode::Char('G'), KeyModifiers::SHIFT, false)]
#[case::caps_input(
"shift g",
KeyCode::Char('G'),
KeyModifiers::SHIFT,
true
)]
#[case::caps_binding(
"shift G",
KeyCode::Char('g'),
KeyModifiers::SHIFT,
true
)]
#[case::multiple_modifiers(
"ctrl shift end",
KeyCode::End,
KeyModifiers::CTRL | KeyModifiers::SHIFT,
true,
)]
#[case::missing_modifier(
"ctrl shift end",
KeyCode::End,
KeyModifiers::SHIFT,
false
)]
fn test_key_combination_matches(
#[case] combination: &str,
#[case] code: KeyCode,
#[case] modifiers: KeyModifiers,
#[case] match_expected: bool,
) {
let combination: KeyCombination = combination.parse().unwrap();
let event = KeyEvent {
code,
modifiers,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
};
assert_eq!(combination.matches(&event), match_expected);
}
#[test]
fn test_key_code() {
let codes = [
KeyCode::Backspace,
KeyCode::Enter,
KeyCode::Left,
KeyCode::Right,
KeyCode::Up,
KeyCode::Down,
KeyCode::Home,
KeyCode::End,
KeyCode::PageUp,
KeyCode::PageDown,
KeyCode::Tab,
KeyCode::Delete,
KeyCode::Insert,
KeyCode::Esc,
KeyCode::CapsLock,
KeyCode::ScrollLock,
KeyCode::NumLock,
KeyCode::PrintScreen,
KeyCode::Pause,
KeyCode::Menu,
KeyCode::KeypadBegin,
]
.into_iter()
.chain((1..=12).map(KeyCode::F))
.chain((32..=126).map(|c| KeyCode::Char(char::from_u32(c).unwrap())))
.chain(
[
MediaKeyCode::Play,
MediaKeyCode::Pause,
MediaKeyCode::PlayPause,
MediaKeyCode::Reverse,
MediaKeyCode::Stop,
MediaKeyCode::FastForward,
MediaKeyCode::Rewind,
MediaKeyCode::TrackNext,
MediaKeyCode::TrackPrevious,
MediaKeyCode::Record,
MediaKeyCode::LowerVolume,
MediaKeyCode::RaiseVolume,
MediaKeyCode::MuteVolume,
]
.into_iter()
.map(KeyCode::Media),
);
for code in codes {
let s = stringify_key_code(code);
let parsed = parse_key_code(&s).unwrap();
assert_eq!(code, parsed, "code parse mismatch");
}
}
#[test]
fn test_key_modifier() {
for modifier in KeyModifiers::all() {
let s = stringify_key_modifier(modifier);
let parsed = parse_key_modifier(&s).unwrap();
assert_eq!(modifier, parsed, "modifier parse mismatch");
}
}
#[test]
fn test_deserialize_input_binding() {
assert_eq!(
deserialize_yaml::<InputBinding>(vec!["f2", "f3"].into()).unwrap(),
InputBinding(vec![KeyCode::F(2).into(), KeyCode::F(3).into()])
);
assert_err!(
deserialize_yaml::<InputBinding>(vec!["no"].into())
.map_err(LocatedError::into_error),
"Invalid key code \"no\""
);
assert_err!(
deserialize_yaml::<InputBinding>(vec!["shart f2"].into())
.map_err(LocatedError::into_error),
"Invalid key modifier \"shart\"; must be one of \
[\"shift\", \"alt\", \"ctrl\", \"super\", \"hyper\", \"meta\"]"
);
assert_err!(
deserialize_yaml::<InputBinding>(vec!["f2", "cortl f3"].into())
.map_err(LocatedError::into_error),
"Invalid key modifier \"cortl\"; must be one of \
[\"shift\", \"alt\", \"ctrl\", \"super\", \"hyper\", \"meta\"]"
);
assert_err!(
deserialize_yaml::<InputBinding>("f3".into())
.map_err(LocatedError::into_error),
"Expected sequence, received \"f3\""
);
assert_err!(
deserialize_yaml::<InputBinding>(3.into())
.map_err(LocatedError::into_error),
"Expected sequence, received `3`"
);
}
#[rstest]
#[case::user_binding(
Action::Submit,
KeyCode::Char('w'),
KeyCode::Char('w'),
Some(Action::Submit)
)]
#[case::default_not_available(
Action::Submit,
KeyCode::Tab,
KeyCode::Enter,
None
)]
#[case::unbound(Action::Submit, InputBinding(vec![]), KeyCode::Enter, None)]
fn test_user_bindings(
#[case] action: Action,
#[case] binding: impl Into<InputBinding>,
#[case] pressed: KeyCode,
#[case] expected: Option<Action>,
) {
let engine = InputMap::new(indexmap! {action => binding.into()});
let event = KeyEvent {
code: pressed,
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
state: KeyEventState::empty(),
};
let actual = engine
.iter()
.find_map(|(action, binding)| {
if binding.matches(&event) {
Some(action)
} else {
None
}
})
.copied();
assert_eq!(actual, expected);
}
}