1#[cfg(test)]
10use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
11
12use crate::input::Command;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub enum Category {
16 Movement,
17 Search,
18 Files,
19 Marks,
20 Tags,
21 Misc,
22}
23
24impl Category {
25 pub fn label(self) -> &'static str {
26 match self {
27 Category::Movement => "Movement",
28 Category::Search => "Search",
29 Category::Files => "Files",
30 Category::Marks => "Marks",
31 Category::Tags => "Tags",
32 Category::Misc => "Misc",
33 }
34 }
35
36 pub const ORDER: &'static [Category] = &[
38 Category::Movement,
39 Category::Search,
40 Category::Files,
41 Category::Marks,
42 Category::Tags,
43 Category::Misc,
44 ];
45}
46
47#[derive(Debug)]
48pub struct KeyEntry {
49 pub keys: &'static [&'static str],
52 pub category: Category,
53 pub description: &'static str,
54 pub command: Command,
55 pub command_name: &'static str,
57}
58
59pub static KEY_REGISTRY: &[KeyEntry] = &[
62 KeyEntry {
64 keys: &["j", "↓", "e", "Enter"],
65 category: Category::Movement,
66 description: "scroll down one line",
67 command: Command::ScrollLines(1),
68 command_name: "scroll-down",
69 },
70 KeyEntry {
71 keys: &["k", "↑", "y"],
72 category: Category::Movement,
73 description: "scroll up one line",
74 command: Command::ScrollLines(-1),
75 command_name: "scroll-up",
76 },
77 KeyEntry {
78 keys: &["J"],
79 category: Category::Movement,
80 description: "next logical line (skip wrap rows)",
81 command: Command::ScrollLogicalLines(1),
82 command_name: "scroll-logical-down",
83 },
84 KeyEntry {
85 keys: &["K"],
86 category: Category::Movement,
87 description: "previous logical line",
88 command: Command::ScrollLogicalLines(-1),
89 command_name: "scroll-logical-up",
90 },
91 KeyEntry {
92 keys: &["Space", "f", "Ctrl-F", "PgDn"],
93 category: Category::Movement,
94 description: "page down",
95 command: Command::PageDown,
96 command_name: "page-down",
97 },
98 KeyEntry {
99 keys: &["b", "Ctrl-B", "PgUp"],
100 category: Category::Movement,
101 description: "page up",
102 command: Command::PageUp,
103 command_name: "page-up",
104 },
105 KeyEntry {
106 keys: &["d", "Ctrl-D"],
107 category: Category::Movement,
108 description: "half page down",
109 command: Command::HalfPageDown,
110 command_name: "half-page-down",
111 },
112 KeyEntry {
113 keys: &["u", "Ctrl-U"],
114 category: Category::Movement,
115 description: "half page up",
116 command: Command::HalfPageUp,
117 command_name: "half-page-up",
118 },
119 KeyEntry {
120 keys: &["g", "<", "Home"],
121 category: Category::Movement,
122 description: "jump to first line (or line N with prefix)",
123 command: Command::GotoLine,
124 command_name: "goto-line",
125 },
126 KeyEntry {
127 keys: &["G", ">", "End"],
128 category: Category::Movement,
129 description: "jump to last line (or record N with prefix)",
130 command: Command::GotoRecord,
131 command_name: "goto-record",
132 },
133 KeyEntry {
134 keys: &["%"],
135 category: Category::Movement,
136 description: "jump to N% through file",
137 command: Command::GotoPercent,
138 command_name: "goto-percent",
139 },
140
141 KeyEntry {
143 keys: &["/"],
144 category: Category::Search,
145 description: "search forward",
146 command: Command::SearchForward,
147 command_name: "search-forward",
148 },
149 KeyEntry {
150 keys: &["?"],
151 category: Category::Search,
152 description: "search backward",
153 command: Command::SearchBackward,
154 command_name: "search-backward",
155 },
156 KeyEntry {
157 keys: &["n"],
158 category: Category::Search,
159 description: "next match",
160 command: Command::NextMatch,
161 command_name: "next-match",
162 },
163 KeyEntry {
164 keys: &["N"],
165 category: Category::Search,
166 description: "previous match",
167 command: Command::PreviousMatch,
168 command_name: "previous-match",
169 },
170
171 KeyEntry {
173 keys: &[":n"],
174 category: Category::Files,
175 description: "next file",
176 command: Command::ColonPrompt, command_name: "next-file",
178 },
179 KeyEntry {
180 keys: &[":p"],
181 category: Category::Files,
182 description: "previous file",
183 command: Command::ColonPrompt,
184 command_name: "prev-file",
185 },
186 KeyEntry {
187 keys: &[":b", ":buffers"],
188 category: Category::Files,
189 description: "open file picker",
190 command: Command::OpenPicker,
191 command_name: "open-picker",
192 },
193 KeyEntry {
194 keys: &[":e PATH"],
195 category: Category::Files,
196 description: "open a new file (add to set)",
197 command: Command::ColonPrompt,
198 command_name: "edit-file",
199 },
200 KeyEntry {
201 keys: &[":d"],
202 category: Category::Files,
203 description: "drop current file from set",
204 command: Command::ColonPrompt,
205 command_name: "drop-file",
206 },
207 KeyEntry {
208 keys: &[":x"],
209 category: Category::Files,
210 description: "jump to first file",
211 command: Command::ColonPrompt,
212 command_name: "first-file",
213 },
214 KeyEntry {
215 keys: &[":t"],
216 category: Category::Files,
217 description: "jump to last file",
218 command: Command::ColonPrompt,
219 command_name: "last-file",
220 },
221
222 KeyEntry {
224 keys: &["m<a-z>"],
225 category: Category::Marks,
226 description: "set mark to current position",
227 command: Command::MarkSet,
228 command_name: "mark-set",
229 },
230 KeyEntry {
231 keys: &["'<a-z>"],
232 category: Category::Marks,
233 description: "jump to mark",
234 command: Command::MarkJump,
235 command_name: "mark-jump",
236 },
237 KeyEntry {
240 keys: &["Ctrl-X Ctrl-X"],
241 category: Category::Marks,
242 description: "jump to previous position",
243 command: Command::JumpPrevious,
244 command_name: "jump-previous",
245 },
246
247 KeyEntry {
249 keys: &["Ctrl-]"],
250 category: Category::Tags,
251 description: "jump to tag (prompts for name)",
252 command: Command::TagPrompt,
253 command_name: "tag-prompt",
254 },
255 KeyEntry {
256 keys: &["Ctrl-T"],
257 category: Category::Tags,
258 description: "pop tag stack",
259 command: Command::TagPop,
260 command_name: "tag-pop",
261 },
262
263 KeyEntry {
265 keys: &["q", "Q", "Ctrl-C"],
266 category: Category::Misc,
267 description: "quit",
268 command: Command::Quit,
269 command_name: "quit",
270 },
271 KeyEntry {
272 keys: &["r", "Ctrl-L"],
273 category: Category::Misc,
274 description: "refresh screen",
275 command: Command::Refresh,
276 command_name: "refresh",
277 },
278 KeyEntry {
279 keys: &["R"],
280 category: Category::Misc,
281 description: "reload source from disk",
282 command: Command::Reload,
283 command_name: "reload",
284 },
285 KeyEntry {
286 keys: &["F"],
287 category: Category::Misc,
288 description: "toggle follow mode",
289 command: Command::ToggleFollow,
290 command_name: "toggle-follow",
291 },
292 KeyEntry {
293 keys: &["P"],
294 category: Category::Misc,
295 description: "toggle prettify",
296 command: Command::TogglePrettify,
297 command_name: "toggle-prettify",
298 },
299 KeyEntry {
300 keys: &["-"],
301 category: Category::Misc,
302 description: "option-toggle prefix (N=lines, S=chop, F=follow)",
303 command: Command::OptionPrefix,
304 command_name: "option-prefix",
305 },
306 KeyEntry {
307 keys: &["!"],
308 category: Category::Misc,
309 description: "shell escape (run external command)",
310 command: Command::ShellEscape,
311 command_name: "shell-escape",
312 },
313 KeyEntry {
314 keys: &[":"],
315 category: Category::Misc,
316 description: "colon command prompt",
317 command: Command::ColonPrompt,
318 command_name: "colon-prompt",
319 },
320 KeyEntry {
321 keys: &["0", "1-9"],
322 category: Category::Misc,
323 description: "numeric prefix (e.g. 5G jumps to record 5)",
324 command: Command::Digit(0),
325 command_name: "digit-prefix",
326 },
327 KeyEntry {
328 keys: &["Esc"],
329 category: Category::Misc,
330 description: "cancel pending numeric prefix or command",
331 command: Command::Cancel,
332 command_name: "cancel",
333 },
334 KeyEntry {
335 keys: &[":help", ":h", "F1"],
336 category: Category::Misc,
337 description: "open this help overlay",
338 command: Command::OpenHelp,
339 command_name: "open-help",
340 },
341];
342
343#[cfg(test)]
347fn parse_canonical_key(spec: &str) -> Option<KeyEvent> {
348 if spec.starts_with(':') || spec.contains(' ') || spec.contains('<') {
349 return None;
350 }
351 let lower = spec.to_lowercase();
352 let mut parts: Vec<&str> = lower.split('-').collect();
353 let key_part = parts.pop()?;
354 let mut modifiers = KeyModifiers::NONE;
355 for m in &parts {
356 match *m {
357 "ctrl" => modifiers |= KeyModifiers::CONTROL,
358 "alt" => modifiers |= KeyModifiers::ALT,
359 "shift" => modifiers |= KeyModifiers::SHIFT,
360 _ => return None,
361 }
362 }
363 let code = match key_part {
364 "esc" => KeyCode::Esc,
365 "enter" => KeyCode::Enter,
366 "tab" => KeyCode::Tab,
367 "backspace" => KeyCode::Backspace,
368 "space" => KeyCode::Char(' '),
369 "↑" | "up" => KeyCode::Up,
370 "↓" | "down" => KeyCode::Down,
371 "←" | "left" => KeyCode::Left,
372 "→" | "right" => KeyCode::Right,
373 "pgup" => KeyCode::PageUp,
374 "pgdn" => KeyCode::PageDown,
375 "home" => KeyCode::Home,
376 "end" => KeyCode::End,
377 s if s.starts_with('f') && s.len() > 1 => {
378 let n: u8 = s[1..].parse().ok()?;
379 KeyCode::F(n)
380 }
381 s if s.chars().count() == 1 => {
382 let ch = spec.chars().last()?;
383 if ch.is_ascii_uppercase() && modifiers == KeyModifiers::NONE {
384 modifiers |= KeyModifiers::SHIFT;
385 KeyCode::Char(ch) } else {
387 KeyCode::Char(ch.to_ascii_lowercase())
388 }
389 }
390 _ => return None,
391 };
392 Some(KeyEvent::new(code, modifiers))
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398 use crossterm::event::Event;
399 use std::collections::HashSet;
400
401 #[test]
402 fn command_names_are_unique() {
403 let mut seen = HashSet::new();
404 for entry in KEY_REGISTRY {
405 assert!(
406 seen.insert(entry.command_name),
407 "duplicate command_name in KEY_REGISTRY: {}",
408 entry.command_name,
409 );
410 }
411 }
412
413 #[test]
414 fn registry_matches_translate_for_single_key_entries() {
415 for entry in KEY_REGISTRY {
416 for &key in entry.keys {
417 let Some(ke) = parse_canonical_key(key) else { continue };
418 let cmd = crate::input::translate(Event::Key(ke));
419 if key.starts_with(':') { continue; }
425 assert_eq!(
426 cmd, entry.command,
427 "registry/translate drift: key={:?} entry={:?} \
428 translate returned {:?} but registry says {:?}",
429 key, entry.command_name, cmd, entry.command,
430 );
431 }
432 }
433 }
434
435 #[test]
436 fn every_category_has_at_least_one_entry() {
437 for cat in Category::ORDER {
438 assert!(
439 KEY_REGISTRY.iter().any(|e| e.category == *cat),
440 "no entries in category {:?}",
441 cat,
442 );
443 }
444 }
445}