1use crate::actions::{PageAction, PageMode};
30use crate::key::{Key, KeyChord, Modifiers, NamedKey, ParseError, parse_keys};
31use std::collections::HashMap;
32
33#[derive(Debug, Clone, Default)]
34pub struct Keymap {
35 leader: Option<char>,
36 normal: ModeMap,
37 visual: ModeMap,
38 command: ModeMap,
39 hint: ModeMap,
40}
41
42#[derive(Debug, Clone, Default)]
43pub(crate) struct ModeMap {
44 root: Node,
45}
46
47#[derive(Debug, Clone, Default)]
48struct Node {
49 action: Option<PageAction>,
50 children: HashMap<KeyChord, Node>,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum Lookup<'a> {
55 Match(&'a PageAction),
57 Pending,
60 NoMatch,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
65pub enum BindError {
66 #[error("parse error: {0}")]
67 Parse(#[from] ParseError),
68 #[error("binding contains <leader> but no leader configured")]
69 NoLeader,
70}
71
72impl Keymap {
73 pub fn new() -> Self {
74 Self::default()
75 }
76
77 pub fn leader(&self) -> Option<char> {
80 self.leader
81 }
82
83 pub fn set_leader(&mut self, leader: char) {
84 self.leader = Some(leader);
85 }
86
87 pub fn bind(
91 &mut self,
92 mode: PageMode,
93 keys: &str,
94 action: PageAction,
95 ) -> Result<(), BindError> {
96 let chords = self.resolve_keys(keys)?;
97 self.mode_map_mut(mode).bind_chords(&chords, action);
98 Ok(())
99 }
100
101 pub fn bind_chords(&mut self, mode: PageMode, chords: &[KeyChord], action: PageAction) {
104 self.mode_map_mut(mode).bind_chords(chords, action);
105 }
106
107 pub fn lookup(&self, mode: PageMode, chords: &[KeyChord]) -> Lookup<'_> {
109 self.mode_map(mode).lookup(chords)
110 }
111
112 pub fn resolve_timeout(&self, mode: PageMode, chords: &[KeyChord]) -> Option<&PageAction> {
115 self.mode_map(mode).resolve_timeout(chords)
116 }
117
118 pub fn entries(&self, mode: PageMode) -> Vec<(Vec<KeyChord>, PageAction)> {
124 let mut out = Vec::new();
125 let mut prefix = Vec::new();
126 self.mode_map(mode).root.collect(&mut prefix, &mut out);
127 out
128 }
129
130 fn resolve_keys(&self, keys: &str) -> Result<Vec<KeyChord>, BindError> {
131 let mut chords = parse_keys(keys)?;
132 for c in &mut chords {
133 if c.key == Key::Named(NamedKey::Leader) {
134 let l = self.leader.ok_or(BindError::NoLeader)?;
135 c.key = Key::Char(l);
136 if l.is_ascii_uppercase() {
139 c.modifiers |= Modifiers::SHIFT;
140 }
141 }
142 }
143 Ok(chords)
144 }
145
146 fn mode_map(&self, mode: PageMode) -> &ModeMap {
147 match mode {
148 PageMode::Normal | PageMode::Pending | PageMode::Insert => &self.normal,
149 PageMode::Visual => &self.visual,
150 PageMode::Command => &self.command,
151 PageMode::Hint => &self.hint,
152 }
153 }
154
155 fn mode_map_mut(&mut self, mode: PageMode) -> &mut ModeMap {
156 match mode {
157 PageMode::Normal | PageMode::Pending | PageMode::Insert => &mut self.normal,
158 PageMode::Visual => &mut self.visual,
159 PageMode::Command => &mut self.command,
160 PageMode::Hint => &mut self.hint,
161 }
162 }
163
164 pub fn audit_default_bindings(_leader: char) -> Vec<(&'static str, &'static str, PageAction)> {
173 let mut rows: Vec<(&'static str, &'static str, PageAction)> = DEFAULT_BINDINGS
174 .iter()
175 .map(|(mode, keys, action)| (mode_label(*mode), *keys, action.clone()))
176 .collect();
177 rows.sort_by(|a, b| a.0.cmp(b.0).then(a.1.cmp(b.1)));
178 rows
179 }
180
181 pub fn missing_default_bindings() -> Vec<&'static str> {
197 let bound: std::collections::HashSet<&'static str> = DEFAULT_BINDINGS
206 .iter()
207 .map(|(_, _, a)| action_kind(a))
208 .collect();
209 let expected = [
210 "ScrollUp",
211 "ScrollDown",
212 "ScrollLeft",
213 "ScrollRight",
214 "ScrollHalfPageDown",
215 "ScrollHalfPageUp",
216 "ScrollFullPageDown",
217 "ScrollFullPageUp",
218 "ScrollTop",
219 "ScrollBottom",
220 "TabNext",
221 "TabPrev",
222 "TabClose",
223 "TabNewRight",
224 "TabNewLeft",
225 "PinTab",
226 "ReopenClosedTab",
227 "PasteUrl",
228 "MoveTabLeft",
229 "MoveTabRight",
230 "HistoryBack",
231 "HistoryForward",
232 "Reload",
233 "ReloadHard",
234 "StopLoading",
235 "OpenOmnibar",
236 "OpenCommandLine",
237 "EnterHintMode",
238 "EnterHintModeBackground",
239 "Find",
240 "FindNext",
241 "FindPrev",
242 "YankUrl",
243 "ZoomIn",
244 "ZoomOut",
245 "ZoomReset",
246 "OpenDevTools",
247 "FocusFirstInput",
248 "ExitInsertMode",
249 ];
250 expected
251 .iter()
252 .copied()
253 .filter(|name| !bound.contains(name))
254 .collect()
255 }
256
257 pub fn default_bindings(leader: char) -> Self {
261 let mut km = Keymap::new();
262 km.set_leader(leader);
263 for &(mode, keys, ref action) in DEFAULT_BINDINGS {
264 km.bind(mode, keys, action.clone())
268 .expect("static default-bindings table parses");
269 }
270 km
271 }
272}
273
274impl Node {
275 fn collect(&self, prefix: &mut Vec<KeyChord>, out: &mut Vec<(Vec<KeyChord>, PageAction)>) {
279 if let Some(a) = &self.action {
280 out.push((prefix.clone(), a.clone()));
281 }
282 for (chord, child) in &self.children {
283 prefix.push(*chord);
284 child.collect(prefix, out);
285 prefix.pop();
286 }
287 }
288}
289
290impl ModeMap {
291 fn bind_chords(&mut self, chords: &[KeyChord], action: PageAction) {
292 let mut node = &mut self.root;
293 for c in chords {
294 node = node.children.entry(*c).or_default();
295 }
296 node.action = Some(action);
297 }
298
299 fn lookup(&self, chords: &[KeyChord]) -> Lookup<'_> {
300 let mut node = &self.root;
301 for c in chords {
302 match node.children.get(c) {
303 Some(n) => node = n,
304 None => return Lookup::NoMatch,
305 }
306 }
307 if let Some(action) = &node.action {
308 if node.children.is_empty() {
313 Lookup::Match(action)
314 } else {
315 Lookup::Pending
316 }
317 } else if node.children.is_empty() {
318 Lookup::NoMatch
319 } else {
320 Lookup::Pending
321 }
322 }
323
324 fn resolve_timeout(&self, chords: &[KeyChord]) -> Option<&PageAction> {
325 let mut node = &self.root;
326 let mut last_action: Option<&PageAction> = None;
327 if let Some(a) = &node.action {
328 last_action = Some(a);
329 }
330 for c in chords {
331 match node.children.get(c) {
332 Some(n) => {
333 node = n;
334 if let Some(a) = &node.action {
335 last_action = Some(a);
336 }
337 }
338 None => break,
339 }
340 }
341 last_action
342 }
343}
344
345fn mode_label(mode: PageMode) -> &'static str {
346 match mode {
347 PageMode::Normal => "normal",
348 PageMode::Visual => "visual",
349 PageMode::Command => "command",
350 PageMode::Hint => "hint",
351 PageMode::Pending => "pending",
352 PageMode::Insert => "insert",
353 }
354}
355
356fn action_kind(a: &PageAction) -> &'static str {
360 match a {
361 PageAction::ScrollUp(_) => "ScrollUp",
362 PageAction::ScrollDown(_) => "ScrollDown",
363 PageAction::ScrollLeft(_) => "ScrollLeft",
364 PageAction::ScrollRight(_) => "ScrollRight",
365 PageAction::ScrollPageUp => "ScrollPageUp",
366 PageAction::ScrollPageDown => "ScrollPageDown",
367 PageAction::ScrollFullPageDown => "ScrollFullPageDown",
368 PageAction::ScrollFullPageUp => "ScrollFullPageUp",
369 PageAction::ScrollHalfPageDown => "ScrollHalfPageDown",
370 PageAction::ScrollHalfPageUp => "ScrollHalfPageUp",
371 PageAction::ScrollTop => "ScrollTop",
372 PageAction::ScrollBottom => "ScrollBottom",
373 PageAction::TabNext => "TabNext",
374 PageAction::TabPrev => "TabPrev",
375 PageAction::TabClose => "TabClose",
376 PageAction::TabNew => "TabNew",
377 PageAction::TabNewRight => "TabNewRight",
378 PageAction::TabNewLeft => "TabNewLeft",
379 PageAction::PinTab => "PinTab",
380 PageAction::ReopenClosedTab => "ReopenClosedTab",
381 PageAction::PasteUrl { .. } => "PasteUrl",
382 PageAction::TabReorder { .. } => "TabReorder",
383 PageAction::MoveTabLeft => "MoveTabLeft",
384 PageAction::MoveTabRight => "MoveTabRight",
385 PageAction::HistoryBack => "HistoryBack",
386 PageAction::HistoryForward => "HistoryForward",
387 PageAction::Reload => "Reload",
388 PageAction::ReloadHard => "ReloadHard",
389 PageAction::StopLoading => "StopLoading",
390 PageAction::OpenOmnibar => "OpenOmnibar",
391 PageAction::OpenCommandLine => "OpenCommandLine",
392 PageAction::EnterHintMode => "EnterHintMode",
393 PageAction::EnterHintModeBackground => "EnterHintModeBackground",
394 PageAction::EnterMode(_) => "EnterMode",
395 PageAction::Find { .. } => "Find",
396 PageAction::FindNext => "FindNext",
397 PageAction::FindPrev => "FindPrev",
398 PageAction::YankUrl => "YankUrl",
399 PageAction::YankSelection => "YankSelection",
400 PageAction::ZoomIn => "ZoomIn",
401 PageAction::ZoomOut => "ZoomOut",
402 PageAction::ZoomReset => "ZoomReset",
403 PageAction::OpenDevTools => "OpenDevTools",
404 PageAction::ClearCompletedDownloads => "ClearCompletedDownloads",
405 PageAction::EnterInsertMode => "EnterInsertMode",
406 PageAction::FocusFirstInput => "FocusFirstInput",
407 PageAction::ExitInsertMode => "ExitInsertMode",
408 }
409}
410
411const DEFAULT_BINDINGS: &[(PageMode, &str, PageAction)] = &[
418 (PageMode::Normal, "j", PageAction::ScrollDown(1)),
420 (PageMode::Normal, "k", PageAction::ScrollUp(1)),
421 (PageMode::Normal, "h", PageAction::ScrollLeft(1)),
422 (PageMode::Normal, "l", PageAction::ScrollRight(1)),
423 (PageMode::Normal, "<Down>", PageAction::ScrollDown(1)),
424 (PageMode::Normal, "<Up>", PageAction::ScrollUp(1)),
425 (PageMode::Normal, "<Left>", PageAction::ScrollLeft(1)),
426 (PageMode::Normal, "<Right>", PageAction::ScrollRight(1)),
427 (PageMode::Normal, "<C-e>", PageAction::ScrollDown(1)),
428 (PageMode::Normal, "<C-y>", PageAction::ScrollUp(1)),
429 (PageMode::Normal, "<C-d>", PageAction::ScrollHalfPageDown),
430 (PageMode::Normal, "<C-u>", PageAction::ScrollHalfPageUp),
431 (PageMode::Normal, "<C-f>", PageAction::ScrollFullPageDown),
432 (PageMode::Normal, "<C-b>", PageAction::ScrollFullPageUp),
433 (
434 PageMode::Normal,
435 "<PageDown>",
436 PageAction::ScrollFullPageDown,
437 ),
438 (PageMode::Normal, "<PageUp>", PageAction::ScrollFullPageUp),
439 (PageMode::Normal, "gg", PageAction::ScrollTop),
440 (PageMode::Normal, "G", PageAction::ScrollBottom),
441 (PageMode::Normal, "<Home>", PageAction::ScrollTop),
442 (PageMode::Normal, "<End>", PageAction::ScrollBottom),
443 (PageMode::Normal, "H", PageAction::TabPrev),
446 (PageMode::Normal, "L", PageAction::TabNext),
447 (PageMode::Normal, "gt", PageAction::TabNext),
448 (PageMode::Normal, "gT", PageAction::TabPrev),
449 (PageMode::Normal, "d", PageAction::TabClose),
450 (PageMode::Normal, "<leader>p", PageAction::PinTab),
454 (PageMode::Normal, "p", PageAction::PasteUrl { after: true }),
458 (PageMode::Normal, "P", PageAction::PasteUrl { after: false }),
459 (PageMode::Normal, "u", PageAction::ReopenClosedTab),
462 (PageMode::Normal, "<C-t>", PageAction::TabNewRight),
466 (PageMode::Normal, "<C-S-t>", PageAction::ReopenClosedTab),
467 (PageMode::Normal, "<C-w>", PageAction::TabClose),
468 (PageMode::Normal, "<C-S-h>", PageAction::MoveTabLeft),
471 (PageMode::Normal, "<C-S-l>", PageAction::MoveTabRight),
472 (PageMode::Normal, "J", PageAction::HistoryBack),
475 (PageMode::Normal, "K", PageAction::HistoryForward),
476 (PageMode::Normal, "<C-o>", PageAction::HistoryBack),
477 (PageMode::Normal, "<C-i>", PageAction::HistoryForward),
478 (PageMode::Normal, "r", PageAction::Reload),
480 (PageMode::Normal, "R", PageAction::ReloadHard),
481 (PageMode::Normal, "<C-r>", PageAction::ReloadHard),
482 (PageMode::Normal, "<Esc>", PageAction::ExitInsertMode),
484 (PageMode::Normal, "<C-c>", PageAction::StopLoading),
486 (PageMode::Normal, "o", PageAction::TabNewRight),
490 (PageMode::Normal, "O", PageAction::TabNewLeft),
491 (PageMode::Normal, "e", PageAction::OpenOmnibar),
493 (PageMode::Normal, "<C-l>", PageAction::OpenOmnibar),
494 (PageMode::Normal, ":", PageAction::OpenCommandLine),
495 (PageMode::Normal, ";", PageAction::OpenCommandLine),
497 (PageMode::Normal, "f", PageAction::EnterHintMode),
499 (PageMode::Normal, "F", PageAction::EnterHintModeBackground),
500 (PageMode::Normal, "/", PageAction::Find { forward: true }),
502 (PageMode::Normal, "?", PageAction::Find { forward: false }),
503 (PageMode::Normal, "n", PageAction::FindNext),
504 (PageMode::Normal, "N", PageAction::FindPrev),
505 (PageMode::Normal, "y", PageAction::YankUrl),
507 (PageMode::Normal, "<C-c>", PageAction::YankUrl),
508 (PageMode::Normal, "+", PageAction::ZoomIn),
513 (PageMode::Normal, "=", PageAction::ZoomIn),
514 (PageMode::Normal, "-", PageAction::ZoomOut),
515 (PageMode::Normal, "_", PageAction::ZoomOut),
516 (PageMode::Normal, "0", PageAction::ZoomReset),
517 (PageMode::Normal, ")", PageAction::ZoomReset),
518 (PageMode::Normal, "<C-0>", PageAction::ZoomReset),
519 (PageMode::Normal, "i", PageAction::FocusFirstInput),
524 (PageMode::Normal, "gi", PageAction::FocusFirstInput),
525 (PageMode::Normal, "<F12>", PageAction::OpenDevTools),
527 (PageMode::Normal, "<C-S-i>", PageAction::OpenDevTools),
528 (PageMode::Visual, "y", PageAction::YankSelection),
536 (PageMode::Visual, "<C-c>", PageAction::YankSelection),
537 (
538 PageMode::Visual,
539 "<Esc>",
540 PageAction::EnterMode(PageMode::Normal),
541 ),
542 (
544 PageMode::Hint,
545 "<Esc>",
546 PageAction::EnterMode(PageMode::Normal),
547 ),
548 (
550 PageMode::Command,
551 "<Esc>",
552 PageAction::EnterMode(PageMode::Normal),
553 ),
554];
555
556#[cfg(test)]
557mod tests {
558 use super::*;
559 use crate::key::parse_keys as pk;
560
561 fn chords(s: &str) -> Vec<KeyChord> {
562 pk(s).expect("parse")
563 }
564
565 #[test]
566 fn empty_lookup_returns_pending() {
567 let mut km = Keymap::new();
568 km.bind(PageMode::Normal, "gg", PageAction::ScrollTop)
569 .unwrap();
570 let r = km.lookup(PageMode::Normal, &[]);
571 assert!(matches!(r, Lookup::Pending));
572 }
573
574 #[test]
575 fn unbound_returns_no_match() {
576 let km = Keymap::new();
577 let r = km.lookup(PageMode::Normal, &chords("xyz"));
578 assert!(matches!(r, Lookup::NoMatch));
579 }
580
581 #[test]
582 fn exact_match_with_no_extension() {
583 let mut km = Keymap::new();
584 km.bind(PageMode::Normal, "<C-w>v", PageAction::TabNew)
585 .unwrap();
586 let r = km.lookup(PageMode::Normal, &chords("<C-w>v"));
587 assert!(matches!(r, Lookup::Match(PageAction::TabNew)));
588 }
589
590 #[test]
591 fn prefix_returns_pending() {
592 let mut km = Keymap::new();
593 km.bind(PageMode::Normal, "<C-w>v", PageAction::TabNew)
594 .unwrap();
595 let r = km.lookup(PageMode::Normal, &chords("<C-w>"));
596 assert!(matches!(r, Lookup::Pending));
597 }
598
599 #[test]
600 fn prefix_conflict_g_vs_gg_pending() {
601 let mut km = Keymap::new();
602 km.bind(PageMode::Normal, "g", PageAction::HistoryBack)
603 .unwrap();
604 km.bind(PageMode::Normal, "gg", PageAction::ScrollTop)
605 .unwrap();
606 let lookup = km.lookup(PageMode::Normal, &chords("g"));
607 assert!(matches!(lookup, Lookup::Pending));
608 let resolved = km.resolve_timeout(PageMode::Normal, &chords("g"));
609 assert!(matches!(resolved, Some(PageAction::HistoryBack)));
610 }
611
612 #[test]
613 fn longer_match_wins_when_extended() {
614 let mut km = Keymap::new();
615 km.bind(PageMode::Normal, "g", PageAction::HistoryBack)
616 .unwrap();
617 km.bind(PageMode::Normal, "gg", PageAction::ScrollTop)
618 .unwrap();
619 let r = km.lookup(PageMode::Normal, &chords("gg"));
620 assert!(matches!(r, Lookup::Match(PageAction::ScrollTop)));
621 }
622
623 #[test]
624 fn rebind_overwrites() {
625 let mut km = Keymap::new();
626 km.bind(PageMode::Normal, "<C-r>", PageAction::Reload)
627 .unwrap();
628 km.bind(PageMode::Normal, "<C-r>", PageAction::HistoryForward)
629 .unwrap();
630 let r = km.lookup(PageMode::Normal, &chords("<C-r>"));
631 assert!(matches!(r, Lookup::Match(PageAction::HistoryForward)));
632 }
633
634 #[test]
635 fn no_match_after_dead_end() {
636 let mut km = Keymap::new();
637 km.bind(PageMode::Normal, "gT", PageAction::TabPrev)
638 .unwrap();
639 let r = km.lookup(PageMode::Normal, &chords("gz"));
640 assert!(matches!(r, Lookup::NoMatch));
641 }
642
643 #[test]
644 fn case_sensitive_letters() {
645 let mut km = Keymap::new();
646 km.bind(PageMode::Normal, "g", PageAction::HistoryBack)
647 .unwrap();
648 km.bind(PageMode::Normal, "G", PageAction::ScrollBottom)
649 .unwrap();
650 assert!(matches!(
651 km.lookup(PageMode::Normal, &chords("G")),
652 Lookup::Match(PageAction::ScrollBottom)
653 ));
654 }
655
656 #[test]
657 fn mode_isolation() {
658 let mut km = Keymap::new();
659 km.bind(PageMode::Normal, "j", PageAction::ScrollDown(1))
660 .unwrap();
661 let r = km.lookup(PageMode::Visual, &chords("j"));
663 assert!(matches!(r, Lookup::NoMatch));
664 }
665
666 #[test]
667 fn leader_resolves_to_configured_char() {
668 let mut km = Keymap::new();
669 km.set_leader('\\');
670 km.bind(PageMode::Normal, "<leader>n", PageAction::TabNew)
671 .unwrap();
672 let r = km.lookup(PageMode::Normal, &chords("\\n"));
674 assert!(matches!(r, Lookup::Match(PageAction::TabNew)));
675 }
676
677 #[test]
678 fn leader_without_config_errors() {
679 let mut km = Keymap::new();
680 let err = km.bind(PageMode::Normal, "<leader>n", PageAction::TabNew);
681 assert!(matches!(err, Err(BindError::NoLeader)));
682 }
683
684 #[test]
685 fn default_bindings_table_parses() {
686 let _km = Keymap::default_bindings('\\');
690 }
691
692 #[test]
693 fn default_j_scrolls_down() {
694 let km = Keymap::default_bindings('\\');
695 let r = km.lookup(PageMode::Normal, &chords("j"));
696 assert!(matches!(r, Lookup::Match(PageAction::ScrollDown(1))));
697 }
698
699 #[test]
700 fn default_gg_top_g_prefix_pending() {
701 let km = Keymap::default_bindings('\\');
702 let r = km.lookup(PageMode::Normal, &chords("g"));
704 assert!(matches!(r, Lookup::Pending));
705 let r = km.lookup(PageMode::Normal, &chords("gg"));
706 assert!(matches!(r, Lookup::Match(PageAction::ScrollTop)));
707 }
708
709 #[test]
710 fn default_ctrl_w_closes_tab() {
711 let km = Keymap::default_bindings('\\');
712 let r = km.lookup(PageMode::Normal, &chords("<C-w>"));
713 assert!(matches!(r, Lookup::Match(PageAction::TabClose)));
714 }
715
716 #[test]
717 fn default_devtools_binding() {
718 let km = Keymap::default_bindings('\\');
719 let r = km.lookup(PageMode::Normal, &chords("<C-S-i>"));
720 assert!(matches!(r, Lookup::Match(PageAction::OpenDevTools)));
721 }
722
723 #[test]
724 fn audit_default_bindings_returns_sorted_rows() {
725 let rows = Keymap::audit_default_bindings('\\');
726 assert!(!rows.is_empty());
727 for w in rows.windows(2) {
729 let (a_mode, a_keys) = (w[0].0, w[0].1);
730 let (b_mode, b_keys) = (w[1].0, w[1].1);
731 let cmp = a_mode.cmp(b_mode).then(a_keys.cmp(b_keys));
732 assert!(cmp.is_le(), "{a_mode}/{a_keys} vs {b_mode}/{b_keys}");
733 }
734 }
735
736 #[test]
737 fn every_user_facing_action_has_a_default_binding() {
738 let missing = Keymap::missing_default_bindings();
739 assert!(missing.is_empty(), "unbound actions: {missing:?}");
740 }
741
742 #[test]
743 fn default_find_forward_and_back() {
744 let km = Keymap::default_bindings('\\');
745 assert!(matches!(
746 km.lookup(PageMode::Normal, &chords("/")),
747 Lookup::Match(PageAction::Find { forward: true })
748 ));
749 assert!(matches!(
750 km.lookup(PageMode::Normal, &chords("?")),
751 Lookup::Match(PageAction::Find { forward: false })
752 ));
753 }
754}