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