Skip to main content

tess/
keymap.rs

1//! Static registry of every default keybinding, with category and human-
2//! readable description. Used by the help overlay; not the runtime dispatcher
3//! (that's `input::translate`). The `registry_matches_translate` test in this
4//! module enforces that the two don't drift.
5//!
6//! User remaps from `~/.config/tess/keys.toml` are layered on top in the help
7//! overlay via `KeyMap::user_keys_by_command_name`.
8
9use 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    /// Render order in the help overlay.
36    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    /// Human-readable display strings, e.g. `&["j", "↓", "Enter"]`. The first
49    /// entry is the canonical one shown when help has no extra room.
50    pub keys: &'static [&'static str],
51    pub category: Category,
52    pub description: &'static str,
53    pub command: Command,
54    /// Stable name used for `keys.toml` remapping (kebab-case).
55    pub command_name: &'static str,
56}
57
58/// Every default binding. The order within a category is the order shown
59/// in the help overlay.
60pub static KEY_REGISTRY: &[KeyEntry] = &[
61    // ── Movement ───────────────────────────────────────────────
62    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    // ── Search ────────────────────────────────────────────────
141    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    // ── Files ─────────────────────────────────────────────────
171    KeyEntry {
172        keys: &[":n"],
173        category: Category::Files,
174        description: "next file",
175        command: Command::ColonPrompt,  // surfaced through colon parser
176        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    // ── Marks ─────────────────────────────────────────────────
222    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    // Two-key chord: first Ctrl-X emits CtrlXPrefix; the second Ctrl-X dispatches JumpPrevious.
237    // The registry records the final intent (JumpPrevious) as that's what users see in help.
238    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    // ── Tags ──────────────────────────────────────────────────
247    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    // ── Misc ──────────────────────────────────────────────────
263    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
342/// Parse the first entry of `keys` into a `KeyEvent`. Used by the sync-check
343/// test. Returns None for entries that don't represent a single keystroke
344/// (e.g. colon commands like ":n", mark sequences like "m<a-z>").
345fn 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)  // keep uppercase: crossterm emits Char('J') + SHIFT, not Char('j') + SHIFT
384            } 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                // For entries whose canonical command isn't reachable through
418                // translate() (e.g. open-help via F1 is reachable, open-picker
419                // via :b is NOT — :b goes through the colon parser), the test
420                // skips when translate returns Noop AND the key spec starts
421                // with ':'. For non-colon entries we require a match.
422                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}