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
9#[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    /// Render order in the help overlay.
37    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    /// Human-readable display strings, e.g. `&["j", "↓", "Enter"]`. The first
50    /// entry is the canonical one shown when help has no extra room.
51    pub keys: &'static [&'static str],
52    pub category: Category,
53    pub description: &'static str,
54    pub command: Command,
55    /// Stable name used for `keys.toml` remapping (kebab-case).
56    pub command_name: &'static str,
57}
58
59/// Every default binding. The order within a category is the order shown
60/// in the help overlay.
61pub static KEY_REGISTRY: &[KeyEntry] = &[
62    // ── Movement ───────────────────────────────────────────────
63    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    // ── Search ────────────────────────────────────────────────
142    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    // ── Files ─────────────────────────────────────────────────
172    KeyEntry {
173        keys: &[":n"],
174        category: Category::Files,
175        description: "next file",
176        command: Command::ColonPrompt,  // surfaced through colon parser
177        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    // ── Marks ─────────────────────────────────────────────────
223    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    // Two-key chord: first Ctrl-X emits CtrlXPrefix; the second Ctrl-X dispatches JumpPrevious.
238    // The registry records the final intent (JumpPrevious) as that's what users see in help.
239    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    // ── Tags ──────────────────────────────────────────────────
248    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    // ── Misc ──────────────────────────────────────────────────
264    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/// Parse the first entry of `keys` into a `KeyEvent`. Used by the sync-check
344/// test. Returns None for entries that don't represent a single keystroke
345/// (e.g. colon commands like ":n", mark sequences like "m<a-z>").
346#[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)  // keep uppercase: crossterm emits Char('J') + SHIFT, not Char('j') + SHIFT
386            } 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                // For entries whose canonical command isn't reachable through
420                // translate() (e.g. open-help via F1 is reachable, open-picker
421                // via :b is NOT — :b goes through the colon parser), the test
422                // skips when translate returns Noop AND the key spec starts
423                // with ':'. For non-colon entries we require a match.
424                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}