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 | | File-view input | Behavior |
| --- | --- | | --- | --- |
| Type / Backspace | Filter in List/explorer INSERT / remove one whole grapheme; ignored in explorer NORMAL | | Type / Backspace | Type query text in List/explorer INSERT / remove one whole grapheme; explorer NORMAL uses hjkl for navigation |
| Ctrl+j/Down/Ctrl+n or Ctrl+k/Up | Wrap forward/backward through visible rows | | j/k (NORMAL); Ctrl+j/k or Up/Down | Wrap forward/backward through visible rows |
| Ctrl+l/Right (explorer NORMAL) | Expand a closed folder; select its first child when already open | | l (NORMAL); Ctrl+l/Right | NORMAL expands/enters a folder; INSERT selects its visible child without changing the filter |
| Ctrl+h/Left (explorer NORMAL) | Collapse an open folder; otherwise select its visible parent | | 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 | | Enter | Toggle a folder, or open the selected file using existing buffer deduplication |
| `i` (explorer NORMAL) | Enter explorer INSERT/filter mode without inserting `i` | | `i` (explorer NORMAL) | Enter explorer INSERT/filter mode without inserting `i` |
| Esc | INSERT → explorer NORMAL, retaining focus; NORMAL → buffer focus or floating close | | 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. 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 ```ruby
config.bind "ctrl+f", config.core.right, mode: config.modes.tree config.bind "ctrl+f", config.core.right, mode: config.modes.tree
config.bind "ctrl+h", nil, 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. 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.file_picker_view = "list"
config.bind "ctrl+j", config.core.down, mode: config.modes.selection config.bind "ctrl+j", config.core.down, mode: config.modes.selection
config.bind "ctrl+k", config.core.up, 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+h", config.core.left, mode: config.modes.tree
config.bind "ctrl+l", config.core.right, mode: config.modes.tree config.bind "ctrl+l", config.core.right, mode: config.modes.tree
config.plugin "plugins/greeting.rb" config.plugin "plugins/greeting.rb"
+149 -28
View File
@@ -709,6 +709,7 @@ pub struct Editor {
selection_navigation_hint: String, selection_navigation_hint: String,
tree_bindings: HashMap<Input, Binding>, tree_bindings: HashMap<Input, Binding>,
tree_navigation_hint: String, tree_navigation_hint: String,
tree_filter_navigation_hint: String,
leader: Input, leader: Input,
leader_bindings: HashMap<Mode, HashMap<Vec<Input>, Binding>>, leader_bindings: HashMap<Mode, HashMap<Vec<Input>, Binding>>,
leader_suffix: Vec<Input>, leader_suffix: Vec<Input>,
@@ -752,11 +753,16 @@ impl Editor {
(Input::Ctrl('h'), Action::Left), (Input::Ctrl('h'), Action::Left),
(Input::Right, Action::Right), (Input::Right, Action::Right),
(Input::Ctrl('l'), 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() .into_iter()
.map(|(key, action)| (key, Binding::Action(action))) .map(|(key, action)| (key, Binding::Action(action)))
.collect(), .collect(),
tree_navigation_hint: String::new(), tree_navigation_hint: String::new(),
tree_filter_navigation_hint: String::new(),
leader: Input::Char(' '), leader: Input::Char(' '),
leader_bindings: HashMap::new(), leader_bindings: HashMap::new(),
leader_suffix: Vec::new(), leader_suffix: Vec::new(),
@@ -1178,47 +1184,70 @@ impl Editor {
} }
pub fn file_navigation_hint(&self) -> &str { pub fn file_navigation_hint(&self) -> &str {
if self if let Some(picker) = &self.file_picker
.file_picker && picker.view == FileView::Tree
.as_ref()
.is_some_and(|picker| picker.view == FileView::Tree && !picker.searching)
{ {
&self.tree_navigation_hint if picker.searching {
&self.tree_filter_navigation_hint
} else {
&self.tree_navigation_hint
}
} else { } else {
&self.selection_navigation_hint &self.selection_navigation_hint
} }
} }
fn refresh_selection_navigation_hint(&mut self) { 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 let mut keys: Vec<_> = self
.selection_bindings .selection_bindings
.iter() .iter()
.filter(|(key, _)| !tree || !self.tree_bindings.contains_key(*key)) .filter(|(key, _)| !applies(key) || !self.tree_bindings.contains_key(*key))
.chain(self.tree_bindings.iter().filter(|_| tree)) .chain(self.tree_bindings.iter().filter(|(key, _)| applies(key)))
.filter( .filter(
|(_, binding)| matches!(binding, Binding::Action(bound) if *bound == action), |(_, 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(); .collect();
keys.sort(); keys.sort();
if keys.is_empty() { if keys.is_empty() {
"unbound".into() "unbound".into()
} else { } 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!( self.selection_navigation_hint = format!(
"{} next · {} previous", "{} next · {} previous",
keys(Action::Down, false), keys(Action::Down, false, false),
keys(Action::Up, false) keys(Action::Up, false, false)
); );
self.tree_navigation_hint = format!( self.tree_navigation_hint = format!(
"{} next · {} previous · {} parent/close · {} open/child", "{} next · {} previous · {} parent/close · {} open/child",
keys(Action::Down, true), keys(Action::Down, true, false),
keys(Action::Up, true), keys(Action::Up, true, false),
keys(Action::Left, true), keys(Action::Left, true, false),
keys(Action::Right, true) 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 let Some(picker) = self
.file_picker .file_picker
.as_mut() .as_mut()
.filter(|picker| picker.focused && picker.view == FileView::Tree && !picker.searching) .filter(|picker| picker.focused && picker.view == FileView::Tree)
else { else {
return false; return false;
}; };
if picker.searching && matches!(input, Input::Char(_)) {
return false;
}
match self.tree_bindings.get(input) { match self.tree_bindings.get(input) {
Some(Binding::Action(Action::Down)) => picker.navigate(true), Some(Binding::Action(Action::Down)) => picker.navigate(true),
Some(Binding::Action(Action::Up)) => picker.navigate(false), Some(Binding::Action(Action::Up)) => picker.navigate(false),
@@ -4024,6 +4056,103 @@ mod tests {
assert_eq!(editor.file_picker().unwrap().entries().len(), 1); 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] #[test]
fn tree_browse_and_search_have_separate_input_and_two_stage_escape() { fn tree_browse_and_search_have_separate_input_and_two_stage_escape() {
let directory = Directory::new(); let directory = Directory::new();
@@ -4119,7 +4248,7 @@ mod tests {
} }
#[test] #[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(); let directory = Directory::new();
fs::create_dir(directory.path("folder")).unwrap(); fs::create_dir(directory.path("folder")).unwrap();
fs::write(directory.path("folder/a.txt"), "a").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())); assert_eq!(editor.leader_pending(), Some(pending.as_str()));
} }
editor.open_file_picker(FilePresentation::Docked); 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')); editor.handle(Input::Char('x'));
assert_eq!(editor.file_picker().unwrap().selected, 0); assert_eq!(editor.file_picker().unwrap().selected, 0);
assert!( assert!(
@@ -4173,17 +4298,13 @@ mod tests {
editor.handle(Input::Ctrl('j')); editor.handle(Input::Ctrl('j'));
assert_eq!(editor.file_picker().unwrap().selected, 0); assert_eq!(editor.file_picker().unwrap().selected, 0);
editor.handle(Input::Char('i')); editor.handle(Input::Char('i'));
assert_eq!(
editor.file_navigation_hint(),
editor.selection_navigation_hint()
);
editor.handle(Input::Char('x')); editor.handle(Input::Char('x'));
assert_eq!(editor.file_picker().unwrap().selected, 1); assert_eq!(editor.file_picker().unwrap().selected, 1);
assert_eq!(editor.file_picker().unwrap().query, ""); assert_eq!(editor.file_picker().unwrap().query, "");
editor.handle(Input::Ctrl('j')); 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')); 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')); editor.handle(Input::Ctrl('t'));
assert_eq!(editor.file_picker().unwrap().view, FileView::List); assert_eq!(editor.file_picker().unwrap().view, FileView::List);
assert_eq!( assert_eq!(
+23
View File
@@ -507,6 +507,18 @@ impl FilePicker {
let Some(row) = self.rows.get(self.selected) else { let Some(row) = self.rows.get(self.selected) else {
return; 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 { if !self.nodes[row.node].is_dir {
return; return;
} }
@@ -532,6 +544,17 @@ impl FilePicker {
let Some(row) = self.rows.get(self.selected) else { let Some(row) = self.rows.get(self.selected) else {
return; 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 { if row.expanded {
let node = row.node; let node = row.node;
let tree = row.tree.expect("directory navigation is tree-only"); 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/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 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) 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 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 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 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')); editor.handle(Input::Ctrl('h'));
assert_eq!(editor.file_picker().unwrap().query, "src/inner/needle.txt"); assert_eq!(editor.file_picker().unwrap().query, "src/inner/needle.txt");
let picker = editor.file_picker().unwrap(); 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!( assert_eq!(
picker.entries().nth(picker.selected).unwrap().path, picker.entries().nth(picker.selected).unwrap().path,
std::path::Path::new("src/inner/needle.txt") std::path::Path::new("src/inner/needle.txt")
); );
assert_eq!(picker.query, "src/inner/needle.txt");
editor.handle(Input::Ctrl('j')); editor.handle(Input::Ctrl('j'));
terminal terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left)) .draw(|frame| draw(frame, &editor, &mut top, &mut left))