From 49242bb6e40e309c80db3e88f2508f82e56240cf Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Thu, 8 Oct 2026 16:29:33 +0200 Subject: [PATCH] feat: use hjkl for explorer Normal navigation --- README.md | 12 +-- examples/config.rb | 4 + src/editor.rs | 177 ++++++++++++++++++++++++++++++++++++++------- src/files.rs | 23 ++++++ src/main.rs | 2 +- src/tui.rs | 7 ++ 6 files changed, 190 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 15f45c8..4fb833d 100644 --- a/README.md +++ b/README.md @@ -102,10 +102,10 @@ New trees start in explorer **NORMAL** mode for navigation. Press `i` to enter e | File-view input | Behavior | | --- | --- | -| Type / Backspace | Filter in List/explorer INSERT / remove one whole grapheme; ignored in explorer NORMAL | -| Ctrl+j/Down/Ctrl+n or Ctrl+k/Up | Wrap forward/backward through visible rows | -| Ctrl+l/Right (explorer NORMAL) | Expand a closed folder; select its first child when already open | -| Ctrl+h/Left (explorer NORMAL) | Collapse an open folder; otherwise select its visible parent | +| Type / Backspace | Type query text in List/explorer INSERT / remove one whole grapheme; explorer NORMAL uses hjkl for navigation | +| j/k (NORMAL); Ctrl+j/k or Up/Down | Wrap forward/backward through visible rows | +| l (NORMAL); Ctrl+l/Right | NORMAL expands/enters a folder; INSERT selects its visible child without changing the filter | +| h (NORMAL); Ctrl+h/Left | NORMAL collapses/selects parent; INSERT selects its visible parent without changing the filter | | Enter | Toggle a folder, or open the selected file using existing buffer deduplication | | `i` (explorer NORMAL) | Enter explorer INSERT/filter mode without inserting `i` | | Esc | INSERT → explorer NORMAL, retaining focus; NORMAL → buffer focus or floating close | @@ -141,14 +141,14 @@ config.bind "ctrl+j", nil, mode: config.modes.selection Selection bindings take precedence while a file or command list is focused. Unbound printable keys filter in List/explorer INSERT, but not explorer NORMAL; a disabled key is consumed without filtering or moving. Only `up`, `down`, and `nil` are accepted—no leader sequences, edits, or plugin commands. Invalid declarations leave existing routes unchanged. Navigation does not change text, carets, history, or the buffer mode. Both list footers show effective keys, including remaps and disabled directions. -Explorer NORMAL additionally uses `config.modes.tree`, accepting `up`, `down`, `left`, `right`, or `nil`. Its single-key routes override shared selection routes only during tree navigation; unmatched keys inherit the shared selection bindings. Defaults are Ctrl+h/Left for collapse/parent and Ctrl+l/Right for expand/child. For example: +Tree views use `config.modes.tree`, accepting `up`, `down`, `left`, `right`, or `nil`. Explorer NORMAL defaults to h/j/k/l for parent/next/previous/child, with the existing control/arrow routes retained. Tree routes override shared selection routes. In explorer INSERT, printable tree keys are not intercepted: h/j/k/l become query text, while control/special-key tree routes still navigate. Ctrl+h/l select visible parents/children in the filtered projection without changing the query or saved folder expansion. Unmatched keys inherit shared selection bindings. Cached hints prioritize plain keys in NORMAL and omit them in INSERT. For example: ```ruby config.bind "ctrl+f", config.core.right, mode: config.modes.tree config.bind "ctrl+h", nil, mode: config.modes.tree ``` -Ctrl+h is **not** a Backspace alias or default deletion key in any mode. Raw Ctrl+h (0x08) and Backspace (DEL/0x7f) are distinct, as are CSI-u events. It navigates only through the tree browsing binding context; it does not delete a query or Insert text. Explicit `config.core.backspace` bindings remain available if desired. +Ctrl+h is **not** a Backspace alias or default deletion key in any mode. Raw Ctrl+h (0x08) and Backspace (DEL/0x7f) are distinct, as are CSI-u events. In the tree it navigates in NORMAL and INSERT without deleting query text; it never deletes file-buffer Insert text by default. Explicit `config.core.backspace` bindings remain available if desired. Ctrl+j is distinct from Enter: in the TUI's raw mode, LF is Ctrl+j and CR is Enter; CSI-u control events also work. No enhanced-terminal negotiation is required. Ctrl+m remains an Enter alias. Outside lists, existing editing bindings are unchanged: Ctrl+k still clones above in Normal/Visual, and explicit Ctrl+j defaults insert a newline in Insert or submit Command/Search prompts. Enter and Ctrl+j can now be rebound independently. diff --git a/examples/config.rb b/examples/config.rb index 3b6ffbf..52331c2 100644 --- a/examples/config.rb +++ b/examples/config.rb @@ -5,6 +5,10 @@ Vamp.configure do |config| config.file_picker_view = "list" config.bind "ctrl+j", config.core.down, mode: config.modes.selection config.bind "ctrl+k", config.core.up, mode: config.modes.selection + config.bind "h", config.core.left, mode: config.modes.tree + config.bind "j", config.core.down, mode: config.modes.tree + config.bind "k", config.core.up, mode: config.modes.tree + config.bind "l", config.core.right, mode: config.modes.tree config.bind "ctrl+h", config.core.left, mode: config.modes.tree config.bind "ctrl+l", config.core.right, mode: config.modes.tree config.plugin "plugins/greeting.rb" diff --git a/src/editor.rs b/src/editor.rs index 0d2b005..8b5c763 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -709,6 +709,7 @@ pub struct Editor { selection_navigation_hint: String, tree_bindings: HashMap, tree_navigation_hint: String, + tree_filter_navigation_hint: String, leader: Input, leader_bindings: HashMap, Binding>>, leader_suffix: Vec, @@ -752,11 +753,16 @@ impl Editor { (Input::Ctrl('h'), Action::Left), (Input::Right, Action::Right), (Input::Ctrl('l'), Action::Right), + (Input::Char('h'), Action::Left), + (Input::Char('j'), Action::Down), + (Input::Char('k'), Action::Up), + (Input::Char('l'), Action::Right), ] .into_iter() .map(|(key, action)| (key, Binding::Action(action))) .collect(), tree_navigation_hint: String::new(), + tree_filter_navigation_hint: String::new(), leader: Input::Char(' '), leader_bindings: HashMap::new(), leader_suffix: Vec::new(), @@ -1178,47 +1184,70 @@ impl Editor { } pub fn file_navigation_hint(&self) -> &str { - if self - .file_picker - .as_ref() - .is_some_and(|picker| picker.view == FileView::Tree && !picker.searching) + if let Some(picker) = &self.file_picker + && picker.view == FileView::Tree { - &self.tree_navigation_hint + if picker.searching { + &self.tree_filter_navigation_hint + } else { + &self.tree_navigation_hint + } } else { &self.selection_navigation_hint } } fn refresh_selection_navigation_hint(&mut self) { - let keys = |action, tree: bool| { + let keys = |action, tree: bool, filtering: bool| { + let applies = |input: &Input| tree && (!filtering || !matches!(input, Input::Char(_))); let mut keys: Vec<_> = self .selection_bindings .iter() - .filter(|(key, _)| !tree || !self.tree_bindings.contains_key(*key)) - .chain(self.tree_bindings.iter().filter(|_| tree)) + .filter(|(key, _)| !applies(key) || !self.tree_bindings.contains_key(*key)) + .chain(self.tree_bindings.iter().filter(|(key, _)| applies(key))) .filter( |(_, binding)| matches!(binding, Binding::Action(bound) if *bound == action), ) - .map(|(input, _)| display_key(input)) + .map(|(input, _)| { + let priority = + usize::from(!(tree && !filtering && matches!(input, Input::Char(_)))); + (priority, display_key(input)) + }) .collect(); keys.sort(); if keys.is_empty() { "unbound".into() } else { - keys.join("/") + let capacity = + keys.iter().map(|(_, key)| key.len()).sum::() + keys.len() - 1; + let mut hint = String::with_capacity(capacity); + for (_, key) in keys { + if !hint.is_empty() { + hint.push('/'); + } + hint.push_str(&key); + } + hint } }; self.selection_navigation_hint = format!( "{} next · {} previous", - keys(Action::Down, false), - keys(Action::Up, false) + keys(Action::Down, false, false), + keys(Action::Up, false, false) ); self.tree_navigation_hint = format!( "{} next · {} previous · {} parent/close · {} open/child", - keys(Action::Down, true), - keys(Action::Up, true), - keys(Action::Left, true), - keys(Action::Right, true) + keys(Action::Down, true, false), + keys(Action::Up, true, false), + keys(Action::Left, true, false), + keys(Action::Right, true, false) + ); + self.tree_filter_navigation_hint = format!( + "{} next · {} previous · {} parent · {} child", + keys(Action::Down, true, true), + keys(Action::Up, true, true), + keys(Action::Left, true, true), + keys(Action::Right, true, true) ); } @@ -1226,10 +1255,13 @@ impl Editor { let Some(picker) = self .file_picker .as_mut() - .filter(|picker| picker.focused && picker.view == FileView::Tree && !picker.searching) + .filter(|picker| picker.focused && picker.view == FileView::Tree) else { return false; }; + if picker.searching && matches!(input, Input::Char(_)) { + return false; + } match self.tree_bindings.get(input) { Some(Binding::Action(Action::Down)) => picker.navigate(true), Some(Binding::Action(Action::Up)) => picker.navigate(false), @@ -4024,6 +4056,103 @@ mod tests { assert_eq!(editor.file_picker().unwrap().entries().len(), 1); } + #[test] + fn tree_hjkl_navigates_normal_and_control_hjkl_navigates_filter_without_changing_text() { + let directory = Directory::new(); + fs::create_dir(directory.path("dir")).unwrap(); + fs::write(directory.path("dir/hjkl-a.txt"), "a").unwrap(); + fs::write(directory.path("dir/hjkl-b.txt"), "b").unwrap(); + for presentation in [FilePresentation::Floating, FilePresentation::Docked] { + let mut editor = project_editor(&directory); + editor.set_file_picker_view("tree").unwrap(); + editor.open_file_picker(presentation); + let selected = |editor: &Editor| { + let picker = editor.file_picker().unwrap(); + picker + .entries() + .nth(picker.selected) + .unwrap() + .path + .to_owned() + }; + editor.handle(Input::Char('l')); + assert!( + editor + .file_picker() + .unwrap() + .entries() + .next() + .unwrap() + .expanded + ); + editor.handle(Input::Char('l')); + assert_eq!(selected(&editor), Path::new("dir/hjkl-a.txt")); + editor.handle(Input::Char('j')); + assert_eq!(selected(&editor), Path::new("dir/hjkl-b.txt")); + editor.handle(Input::Char('k')); + assert_eq!(selected(&editor), Path::new("dir/hjkl-a.txt")); + editor.handle(Input::Char('h')); + assert_eq!(selected(&editor), Path::new("dir")); + assert!( + editor + .file_picker() + .unwrap() + .entries() + .next() + .unwrap() + .expanded + ); + editor.handle(Input::Char('h')); + assert!( + !editor + .file_picker() + .unwrap() + .entries() + .next() + .unwrap() + .expanded + ); + editor.handle(Input::Char('i')); + type_text(&mut editor, "hjkl"); + assert_eq!(editor.file_picker().unwrap().query, "hjkl"); + assert_eq!(selected(&editor), Path::new("dir/hjkl-a.txt")); + editor.handle(Input::Ctrl('j')); + assert_eq!(selected(&editor), Path::new("dir/hjkl-b.txt")); + editor.handle(Input::Ctrl('k')); + assert_eq!(selected(&editor), Path::new("dir/hjkl-a.txt")); + editor.handle(Input::Ctrl('h')); + assert_eq!(selected(&editor), Path::new("dir")); + editor.handle(Input::Ctrl('l')); + assert_eq!(selected(&editor), Path::new("dir/hjkl-a.txt")); + assert_eq!(editor.file_picker().unwrap().query, "hjkl"); + editor.handle(Input::Escape); + assert!(editor.file_picker().unwrap().focused); + assert_eq!(selected(&editor), Path::new("dir")); + assert!( + !editor + .file_picker() + .unwrap() + .entries() + .next() + .unwrap() + .expanded + ); + editor.bind("tree", "j", Some("core.up".into())).unwrap(); + editor.bind("tree", "k", None).unwrap(); + editor.handle(Input::Char('l')); + editor.handle(Input::Char('j')); + assert_eq!(selected(&editor), Path::new("dir/hjkl-b.txt")); + editor.handle(Input::Char('k')); + assert_eq!(selected(&editor), Path::new("dir/hjkl-b.txt")); + editor.handle(Input::Char('i')); + type_text(&mut editor, "jk"); + assert_eq!(editor.file_picker().unwrap().query, "jk"); + assert_eq!(editor.lines(), &[String::new()]); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.cursor(), Cursor::default()); + } + } + #[test] fn tree_browse_and_search_have_separate_input_and_two_stage_escape() { let directory = Directory::new(); @@ -4119,7 +4248,7 @@ mod tests { } #[test] - fn tree_bindings_override_selection_only_in_browse_and_hints_are_effective() { + fn tree_binding_precedence_is_modal_and_navigation_preferences_are_atomic() { let directory = Directory::new(); fs::create_dir(directory.path("folder")).unwrap(); fs::write(directory.path("folder/a.txt"), "a").unwrap(); @@ -4151,10 +4280,6 @@ mod tests { assert_eq!(editor.leader_pending(), Some(pending.as_str())); } editor.open_file_picker(FilePresentation::Docked); - assert_eq!( - editor.file_navigation_hint(), - "Ctrl+n/Down next · Up previous · Ctrl+h/Ctrl+j/Left parent/close · Ctrl+l/Right/x open/child" - ); editor.handle(Input::Char('x')); assert_eq!(editor.file_picker().unwrap().selected, 0); assert!( @@ -4173,17 +4298,13 @@ mod tests { editor.handle(Input::Ctrl('j')); assert_eq!(editor.file_picker().unwrap().selected, 0); editor.handle(Input::Char('i')); - assert_eq!( - editor.file_navigation_hint(), - editor.selection_navigation_hint() - ); editor.handle(Input::Char('x')); assert_eq!(editor.file_picker().unwrap().selected, 1); assert_eq!(editor.file_picker().unwrap().query, ""); editor.handle(Input::Ctrl('j')); - assert_eq!(editor.file_picker().unwrap().selected, 2); + assert_eq!(editor.file_picker().unwrap().selected, 0); editor.handle(Input::Ctrl('h')); - assert_eq!(editor.file_picker().unwrap().selected, 2); + assert_eq!(editor.file_picker().unwrap().selected, 0); editor.handle(Input::Ctrl('t')); assert_eq!(editor.file_picker().unwrap().view, FileView::List); assert_eq!( diff --git a/src/files.rs b/src/files.rs index 92be508..70f387f 100644 --- a/src/files.rs +++ b/src/files.rs @@ -507,6 +507,18 @@ impl FilePicker { let Some(row) = self.rows.get(self.selected) else { return; }; + if self.searching { + if self + .rows + .get(self.selected + 1) + .is_some_and(|child| child.depth > row.depth) + { + self.selected += 1; + } + self.filtered_selection[self.view.index()] = self.selected_node(); + self.clear_error(); + return; + } if !self.nodes[row.node].is_dir { return; } @@ -532,6 +544,17 @@ impl FilePicker { let Some(row) = self.rows.get(self.selected) else { return; }; + if self.searching { + if let Some(parent) = row.tree.and_then(|tree| self.tree[tree].parent) { + let node = self.tree[parent].node; + if let Some(index) = self.rows.iter().position(|row| row.node == node) { + self.selected = index; + } + } + self.filtered_selection[self.view.index()] = self.selected_node(); + self.clear_error(); + return; + } if row.expanded { let node = row.node; let tree = row.tree.expect("directory navigation is tree-only"); diff --git a/src/main.rs b/src/main.rs index f4781be..fb154be 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,7 +11,7 @@ All modes: Ctrl+p command palette; type to filter, Ctrl+j/k or arrows select, En Normal/Visual: Space is leader; Space Space opens palette; Esc cancels prefix Normal leader: Space w saves; Space b n/p/l/d next/previous/list/close buffer Files: Space o floating list; Space e docked compact tree (right by default) - Ctrl+j/k select; tree Ctrl+l/h expand/parent, Enter toggles/opens + tree NORMAL: hjkl navigate; INSERT: type to filter, Ctrl+hjkl navigate; Enter toggles/opens i filters in tree INSERT; Esc returns NORMAL, then editor; Ctrl+t view, Ctrl+w close, Ctrl+r refresh Normal: i/a insert, I/A insert at first nonblank/end, o/O new line h/j/k/l move, 0/$ line ends, x delete, J join lines diff --git a/src/tui.rs b/src/tui.rs index c7353cf..72836e1 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1323,10 +1323,17 @@ mod tests { editor.handle(Input::Ctrl('h')); assert_eq!(editor.file_picker().unwrap().query, "src/inner/needle.txt"); let picker = editor.file_picker().unwrap(); + assert_eq!( + picker.entries().nth(picker.selected).unwrap().path, + std::path::Path::new("src/inner") + ); + editor.handle(Input::Ctrl('l')); + let picker = editor.file_picker().unwrap(); assert_eq!( picker.entries().nth(picker.selected).unwrap().path, std::path::Path::new("src/inner/needle.txt") ); + assert_eq!(picker.query, "src/inner/needle.txt"); editor.handle(Input::Ctrl('j')); terminal .draw(|frame| draw(frame, &editor, &mut top, &mut left))