1use std::{collections::HashMap, fmt::Display, str::FromStr};
2
3use anyhow::{Context, bail};
4use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
5use ratatui::style::{Color, Modifier, Style};
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Serialize, Deserialize, PartialEq)]
9#[serde(rename_all = "snake_case")]
10pub enum Action {
11 Noop,
12 Exit,
13 Confirm,
14 ToggleSearchReplace,
15 ToggleIgnoreCase,
16 ToggleMultiLine,
17 CursorLeft,
18 CursorRight,
19 CursorHome,
20 CursorEnd,
21 DeleteChar,
22 DeleteCharBackward,
23 DeleteWord,
24 DeleteToEndOfLine,
25 DeleteLine,
26 ScrollDown,
27 ScrollUp,
28 ScrollTop,
29}
30
31#[derive(Debug, PartialOrd, PartialEq, Eq, Clone, Copy, Hash, Serialize, Deserialize)]
32#[serde(try_from = "String")]
33#[serde(into = "String")]
34pub struct Key {
35 code: KeyCode,
36 modifiers: KeyModifiers,
37}
38
39#[cfg(test)]
40impl Key {
41 fn new(code: KeyCode, modifiers: KeyModifiers) -> Key {
42 Key { code, modifiers }
43 }
44
45 fn char(c: char, modifiers: KeyModifiers) -> Key {
46 Key::new(KeyCode::Char(c), modifiers)
47 }
48}
49
50impl From<KeyEvent> for Key {
51 fn from(value: KeyEvent) -> Self {
52 Self {
53 code: value.code,
54 modifiers: value.modifiers,
55 }
56 }
57}
58
59impl TryFrom<String> for Key {
60 type Error = anyhow::Error;
61
62 fn try_from(s: String) -> Result<Self, Self::Error> {
63 let s = s.to_lowercase();
64 let (modifiers, code) = match s.rsplit_once("-") {
65 Some((mod_str, code)) => {
66 let mut modifiers = KeyModifiers::empty();
67 for m in mod_str.split('-') {
68 modifiers |= match m {
69 "c" => KeyModifiers::CONTROL,
70 "a" => KeyModifiers::ALT,
71 _ => {
72 bail!("Unknown modifier '{m}'");
73 }
74 }
75 }
76 (modifiers, code)
77 }
78 None => (KeyModifiers::empty(), s.as_str()),
79 };
80 let code = match code {
81 "backspace" => KeyCode::Backspace,
82 "enter" => KeyCode::Enter,
83 "left" => KeyCode::Left,
84 "right" => KeyCode::Right,
85 "up" => KeyCode::Up,
86 "down" => KeyCode::Down,
87 "home" => KeyCode::Home,
88 "end" => KeyCode::End,
89 "pageup" => KeyCode::PageUp,
90 "pagedown" => KeyCode::PageDown,
91 "tab" => KeyCode::Tab,
92 "backtab" => KeyCode::BackTab,
93 "delete" => KeyCode::Delete,
94 "insert" => KeyCode::Insert,
95 "esc" => KeyCode::Esc,
96 _ => {
97 if code.len() == 1 && code.is_ascii() {
98 KeyCode::Char(code.chars().next().unwrap())
99 } else if let Some(f) = s.strip_prefix("f") {
100 let f: u8 = f
102 .parse()
103 .with_context(|| format!("'{code}' is not a valid F-key"))?;
104 KeyCode::F(f)
105 } else {
106 bail!("Unknown key code '{code}'");
107 }
108 }
109 };
110
111 Ok(Key { code, modifiers })
112 }
113}
114
115#[test]
116fn test_key_from_string() {
117 assert_eq!(
118 Key::char('x', KeyModifiers::empty()),
119 "x".to_string().try_into().unwrap(),
120 );
121
122 assert_eq!(
123 Key::char('y', KeyModifiers::CONTROL),
124 "c-y".to_string().try_into().unwrap(),
125 );
126
127 assert_eq!(
128 Key::char('z', KeyModifiers::ALT),
129 "a-z".to_string().try_into().unwrap(),
130 );
131
132 assert_eq!(
133 Key::char('a', KeyModifiers::CONTROL | KeyModifiers::ALT),
134 "c-a-a".to_string().try_into().unwrap(),
135 );
136
137 assert!(Key::try_from("x-a-a".to_string()).is_err());
138}
139
140impl From<Key> for String {
141 fn from(val: Key) -> Self {
142 let mut s = String::new();
143 if val.modifiers.contains(KeyModifiers::CONTROL) {
144 s += "c-";
145 }
146 if val.modifiers.contains(KeyModifiers::ALT) {
147 s += "a-";
148 }
149 match val.code {
150 KeyCode::Backspace => s += "backspace",
151 KeyCode::Enter => s += "enter",
152 KeyCode::Left => s += "left",
153 KeyCode::Right => s += "right",
154 KeyCode::Up => s += "up",
155 KeyCode::Down => s += "down",
156 KeyCode::Home => s += "home",
157 KeyCode::End => s += "end",
158 KeyCode::PageUp => s += "pageup",
159 KeyCode::PageDown => s += "pagedown",
160 KeyCode::Tab => s += "tab",
161 KeyCode::BackTab => s += "backtab",
162 KeyCode::Delete => s += "delete",
163 KeyCode::Insert => s += "insert",
164 KeyCode::Esc => s += "esc",
165 KeyCode::F(f) => s += format!("f{f}").as_str(),
166 KeyCode::Char(c) => s.push(c.to_ascii_lowercase()),
167 _ => unimplemented!(),
168 };
169
170 s
171 }
172}
173
174impl Display for Key {
175 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176 f.write_str(&String::from(*self))
177 }
178}
179
180#[derive(Debug, Serialize, Deserialize, PartialEq)]
181pub struct KeyMap(HashMap<Key, Action>);
182
183#[derive(Debug, Serialize, Deserialize, PartialEq)]
184#[serde(default)]
185pub struct Theme {
186 pub base: Style,
187 pub find: Style,
188 pub replace: Style,
189}
190
191impl Default for Theme {
192 fn default() -> Self {
193 Self {
194 base: Style {
195 fg: Some(Color::Reset),
196 ..Default::default()
197 },
198 find: Style {
199 fg: Some(Color::Red),
200 add_modifier: Modifier::CROSSED_OUT,
201 ..Default::default()
202 },
203 replace: Style {
204 fg: Some(Color::Green),
205 add_modifier: Modifier::BOLD,
206 ..Default::default()
207 },
208 }
209 }
210}
211
212#[derive(Debug, Serialize, Deserialize, PartialEq)]
213#[serde(default)]
214pub struct Config {
215 pub theme: Theme,
216 pub keys: HashMap<Key, Action>,
217 pub auto_pairs: bool,
218 pub threads: usize,
219}
220
221impl Default for Config {
222 fn default() -> Self {
223 Self {
224 theme: Theme::default(),
225 keys: [
226 ("enter", Action::Confirm),
227 ("esc", Action::Exit),
228 ("c-c", Action::Exit),
229 ("tab", Action::ToggleSearchReplace),
230 ("c-s", Action::ToggleIgnoreCase),
231 ("c-l", Action::ToggleMultiLine),
232 ("left", Action::CursorLeft),
233 ("c-b", Action::CursorLeft),
234 ("right", Action::CursorRight),
235 ("c-f", Action::CursorRight),
236 ("home", Action::CursorHome),
237 ("c-a", Action::CursorHome),
238 ("end", Action::CursorEnd),
239 ("c-e", Action::CursorEnd),
240 ("backspace", Action::DeleteCharBackward),
241 ("c-h", Action::DeleteCharBackward),
242 ("c-d", Action::DeleteChar),
243 ("c-w", Action::DeleteWord),
244 ("c-k", Action::DeleteToEndOfLine),
245 ("c-u", Action::DeleteLine),
246 ("c-n", Action::ScrollDown),
247 ("c-p", Action::ScrollUp),
248 ("c-g", Action::ScrollTop),
249 ]
250 .map(|(k, v)| (k.to_string().try_into().unwrap(), v))
251 .into(),
252 auto_pairs: true,
253 threads: 0,
254 }
255 }
256}
257
258impl FromStr for Config {
259 type Err = anyhow::Error;
260
261 fn from_str(s: &str) -> Result<Self, Self::Err> {
262 let mut c: Config = toml::from_str(s)?;
263 let base = Self::default();
264 for (k, v) in base.keys {
266 c.keys.entry(k).or_insert(v);
267 }
268 Ok(c)
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275 use pretty_assertions::assert_eq;
276 use ratatui::style::Modifier;
277
278 #[test]
279 fn test_config_valid() {
280 let t = toml::toml! {
281 auto_pairs = false
282
283 [theme]
284 base.fg = "6"
285 find.fg = "#00FF00"
286 find.add_modifier = "BOLD"
287
288 [keys]
289 c-x = "exit"
290 }
291 .to_string();
292
293 let c: Config = t.parse().unwrap();
294 let mut keys = Config::default().keys;
295 keys.insert(
296 Key {
297 code: KeyCode::Char('x'),
298 modifiers: KeyModifiers::CONTROL,
299 },
300 Action::Exit,
301 );
302
303 assert_eq!(
304 c,
305 Config {
306 keys,
307 theme: Theme {
308 base: Style {
309 fg: Some(Color::Indexed(6)),
310 ..Default::default()
311 },
312 find: Style {
313 fg: Some(Color::Rgb(0, 255, 0)),
314 add_modifier: Modifier::BOLD,
315 ..Default::default()
316 },
317 replace: Style {
318 fg: Some(Color::Green),
319 add_modifier: Modifier::BOLD,
320 ..Default::default()
321 },
322 },
323 auto_pairs: false,
324 threads: 0,
325 }
326 )
327 }
328}