feat: use hjkl for explorer Normal navigation

This commit is contained in:
Valdemar Lorenzen
2026-10-08 16:29:33 +02:00
parent 5b8e5fbfbf
commit 49242bb6e4
6 changed files with 190 additions and 35 deletions
+6 -6
View File
@@ -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.
+4
View File
@@ -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"
+148 -27
View File
@@ -709,6 +709,7 @@ pub struct Editor {
selection_navigation_hint: String,
tree_bindings: HashMap<Input, Binding>,
tree_navigation_hint: String,
tree_filter_navigation_hint: String,
leader: Input,
leader_bindings: HashMap<Mode, HashMap<Vec<Input>, Binding>>,
leader_suffix: Vec<Input>,
@@ -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
{
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::<usize>() + 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!(
+23
View File
@@ -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");
+1 -1
View File
@@ -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
+7
View File
@@ -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))