feat: use hjkl for explorer Normal navigation
This commit is contained in:
+149
-28
@@ -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!(
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user