1use std::fmt;
8use std::str::FromStr;
9
10use thiserror::Error;
11
12use crate::shell::Shell;
13
14pub const DEFAULT: &str = "ctrl-g";
16
17const RESERVED: &[(char, &str)] = &[
20 ('c', "interrupts the running command"),
21 ('d', "ends input"),
22 ('i', "is the tab key"),
23 ('j', "is a line feed"),
24 ('m', "is the enter key"),
25 ('q', "resumes a stopped terminal"),
26 ('s', "stops terminal output"),
27 ('z', "suspends the running command"),
28];
29
30#[derive(Debug, Error, PartialEq, Eq)]
31pub enum ChordError {
32 #[error("write the key as ctrl-<letter> or alt-<letter>, such as {DEFAULT}")]
33 Shape,
34
35 #[error("{0} is not a modifier lore can bind, use ctrl or alt")]
36 Modifier(String),
37
38 #[error("{0} is not a single letter")]
39 Key(String),
40
41 #[error("ctrl-{0} cannot be bound because it {1}")]
42 Reserved(char, &'static str),
43}
44
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46enum Modifier {
47 Ctrl,
48 Alt,
49}
50
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56pub struct Chord {
57 modifier: Modifier,
58 letter: char,
59}
60
61impl Chord {
62 pub fn render(self, shell: Shell) -> String {
64 let letter = self.letter;
65 match (shell, self.modifier) {
66 (Shell::Bash, Modifier::Ctrl) => format!(r"\C-{letter}"),
67 (Shell::Bash, Modifier::Alt) => format!(r"\M-{letter}"),
68 (Shell::Zsh, Modifier::Ctrl) => format!("^{}", letter.to_ascii_uppercase()),
71 (Shell::Zsh, Modifier::Alt) => format!("^[{letter}"),
72 (Shell::Fish, Modifier::Ctrl) => format!(r"\c{letter}"),
73 (Shell::Fish, Modifier::Alt) => format!(r"\e{letter}"),
74 (Shell::PowerShell, Modifier::Ctrl) => format!("Ctrl+{letter}"),
75 (Shell::PowerShell, Modifier::Alt) => format!("Alt+{letter}"),
76 }
77 }
78
79 pub fn spoken(self) -> String {
81 let modifier = match self.modifier {
82 Modifier::Ctrl => "ctrl",
83 Modifier::Alt => "alt",
84 };
85 format!("{modifier}+{}", self.letter)
86 }
87
88 pub fn is_default(self) -> bool {
89 self == Self::default()
90 }
91}
92
93impl Default for Chord {
94 fn default() -> Self {
95 DEFAULT.parse().expect("the default chord should parse")
96 }
97}
98
99impl FromStr for Chord {
100 type Err = ChordError;
101
102 fn from_str(text: &str) -> Result<Self, Self::Err> {
103 let text = text.trim().to_ascii_lowercase();
104 let (modifier, key) = text.split_once('-').ok_or(ChordError::Shape)?;
105
106 let modifier = match modifier {
107 "ctrl" | "control" => Modifier::Ctrl,
108 "alt" | "meta" => Modifier::Alt,
109 other => return Err(ChordError::Modifier(other.to_string())),
110 };
111
112 let mut letters = key.chars();
113 let letter = match (letters.next(), letters.next()) {
114 (Some(letter), None) if letter.is_ascii_alphabetic() => letter,
115 _ => return Err(ChordError::Key(key.to_string())),
116 };
117
118 if modifier == Modifier::Ctrl
119 && let Some((_, why)) = RESERVED.iter().find(|(reserved, _)| *reserved == letter)
120 {
121 return Err(ChordError::Reserved(letter, why));
122 }
123
124 Ok(Self { modifier, letter })
125 }
126}
127
128impl fmt::Display for Chord {
129 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130 let modifier = match self.modifier {
131 Modifier::Ctrl => "ctrl",
132 Modifier::Alt => "alt",
133 };
134 write!(f, "{modifier}-{}", self.letter)
135 }
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141
142 fn chord(text: &str) -> Chord {
143 text.parse().expect("should parse")
144 }
145
146 #[test]
147 fn the_default_is_the_chord_the_documentation_promises() {
148 assert_eq!(Chord::default().to_string(), "ctrl-g");
149 assert!(chord("ctrl-g").is_default());
150 assert!(!chord("alt-r").is_default());
151 }
152
153 #[test]
154 fn parsing_is_case_insensitive_and_round_trips() {
155 for text in ["ctrl-g", "CTRL-G", " Ctrl-g "] {
156 assert_eq!(chord(text).to_string(), "ctrl-g");
157 }
158 assert_eq!(chord("Alt-R").to_string(), "alt-r");
159 assert_eq!(chord("control-p").to_string(), "ctrl-p");
160 assert_eq!(chord("meta-p").to_string(), "alt-p");
161 }
162
163 #[test]
164 fn every_shell_gets_its_own_spelling() {
165 let ctrl = chord("ctrl-g");
166 assert_eq!(ctrl.render(Shell::Bash), r"\C-g");
167 assert_eq!(ctrl.render(Shell::Zsh), "^G");
168 assert_eq!(ctrl.render(Shell::Fish), r"\cg");
169 assert_eq!(ctrl.render(Shell::PowerShell), "Ctrl+g");
170
171 let alt = chord("alt-r");
172 assert_eq!(alt.render(Shell::Bash), r"\M-r");
173 assert_eq!(alt.render(Shell::Zsh), "^[r");
174 assert_eq!(alt.render(Shell::Fish), r"\er");
175 assert_eq!(alt.render(Shell::PowerShell), "Alt+r");
176 }
177
178 #[test]
179 fn a_chord_without_a_modifier_is_refused() {
180 assert_eq!("g".parse::<Chord>(), Err(ChordError::Shape));
181 assert_eq!("".parse::<Chord>(), Err(ChordError::Shape));
182 }
183
184 #[test]
185 fn an_unbindable_modifier_is_refused() {
186 assert!(matches!(
187 "shift-g".parse::<Chord>(),
188 Err(ChordError::Modifier(_))
189 ));
190 assert!(matches!(
191 "ctrl-shift-g".parse::<Chord>(),
192 Err(ChordError::Key(_))
193 ));
194 }
195
196 #[test]
197 fn a_key_that_is_not_one_letter_is_refused() {
198 for text in ["ctrl-", "ctrl-space", "ctrl-1", "alt-f4"] {
199 assert!(
200 matches!(text.parse::<Chord>(), Err(ChordError::Key(_))),
201 "{text}"
202 );
203 }
204 }
205
206 #[test]
209 fn control_keys_the_terminal_owns_are_refused() {
210 for (letter, _) in RESERVED {
211 let text = format!("ctrl-{letter}");
212 assert!(
213 matches!(text.parse::<Chord>(), Err(ChordError::Reserved(..))),
214 "{text}"
215 );
216 }
217 assert!("alt-c".parse::<Chord>().is_ok());
220 }
221
222 #[test]
223 fn the_spoken_form_reads_like_a_key_to_press() {
224 assert_eq!(chord("ctrl-g").spoken(), "ctrl+g");
225 assert_eq!(chord("alt-r").spoken(), "alt+r");
226 }
227}