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
+149 -28
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
{
&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::<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!(