feat: add configurable Ctrl+j/k list navigation
This commit is contained in:
+250
-33
@@ -156,7 +156,9 @@ fn translate(key: KeyEvent) -> Option<Input> {
|
||||
return None;
|
||||
}
|
||||
Some(match key.code {
|
||||
KeyCode::Char(c) if key.modifiers.contains(KeyModifiers::CONTROL) => Input::Ctrl(c),
|
||||
KeyCode::Char(c) if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
Input::Ctrl(c.to_ascii_lowercase())
|
||||
}
|
||||
KeyCode::Char(c) => Input::Char(c),
|
||||
KeyCode::Esc => Input::Escape,
|
||||
KeyCode::Enter => Input::Enter,
|
||||
@@ -425,13 +427,18 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
|
||||
])
|
||||
.areas(column);
|
||||
frame.render_widget(Clear, panel);
|
||||
draw_file_list(frame, picker, panel);
|
||||
draw_file_list(frame, picker, panel, editor.selection_navigation_hint());
|
||||
} else if let Some(panel) = dock {
|
||||
draw_file_list(frame, picker, panel);
|
||||
draw_file_list(frame, picker, panel, editor.selection_navigation_hint());
|
||||
}
|
||||
}
|
||||
if let Some(palette) = editor.palette() {
|
||||
draw_palette(frame, palette, editor.mode());
|
||||
draw_palette(
|
||||
frame,
|
||||
palette,
|
||||
editor.mode(),
|
||||
editor.selection_navigation_hint(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,7 +482,7 @@ fn display_text(text: &str) -> String {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn draw_file_list(frame: &mut Frame, picker: &FilePicker, panel: Rect) {
|
||||
fn draw_file_list(frame: &mut Frame, picker: &FilePicker, panel: Rect, navigation_hint: &str) {
|
||||
if panel.width == 0 || panel.height == 0 {
|
||||
return;
|
||||
}
|
||||
@@ -582,12 +589,13 @@ fn draw_file_list(frame: &mut Frame, picker: &FilePicker, panel: Rect) {
|
||||
frame.render_widget(Paragraph::new(lines), results_area);
|
||||
}
|
||||
let help = if picker.focused {
|
||||
match picker.presentation {
|
||||
FilePresentation::Floating => "↑↓ Enter · Esc/^W close · ^R scan",
|
||||
FilePresentation::Docked => "↑↓ Enter · Esc/Tab edit · ^W · ^R",
|
||||
}
|
||||
let controls = match picker.presentation {
|
||||
FilePresentation::Floating => "Enter · Esc/^W close · ^R scan",
|
||||
FilePresentation::Docked => "Enter · Esc/Tab edit · ^W · ^R",
|
||||
};
|
||||
format!("{navigation_hint} · {controls}")
|
||||
} else {
|
||||
"Editor focus · invoke explorer to search"
|
||||
"Editor focus · invoke explorer to search".into()
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(help).style(Style::default().fg(Color::DarkGray)),
|
||||
@@ -595,7 +603,7 @@ fn draw_file_list(frame: &mut Frame, picker: &FilePicker, panel: Rect) {
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_palette(frame: &mut Frame, palette: &Palette, mode: Mode) {
|
||||
fn draw_palette(frame: &mut Frame, palette: &Palette, mode: Mode, navigation_hint: &str) {
|
||||
let area = frame.area();
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return;
|
||||
@@ -685,7 +693,7 @@ fn draw_palette(frame: &mut Frame, palette: &Palette, mode: Mode) {
|
||||
}
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!(
|
||||
"{mode} · ↑↓/Ctrl+k/n select · Enter accept · Esc/Ctrl+p cancel"
|
||||
"{mode} · {navigation_hint} · Enter accept · Esc/Ctrl+p cancel"
|
||||
)),
|
||||
help_area,
|
||||
);
|
||||
@@ -738,6 +746,106 @@ mod tests {
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translate_distinguishes_control_j_from_enter_and_normalizes_control_letters() {
|
||||
for (code, modifiers, expected) in [
|
||||
(
|
||||
KeyCode::Char('j'),
|
||||
KeyModifiers::CONTROL,
|
||||
Some(Input::Ctrl('j')),
|
||||
),
|
||||
(
|
||||
KeyCode::Char('J'),
|
||||
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
|
||||
Some(Input::Ctrl('j')),
|
||||
),
|
||||
(
|
||||
KeyCode::Char('k'),
|
||||
KeyModifiers::CONTROL,
|
||||
Some(Input::Ctrl('k')),
|
||||
),
|
||||
(KeyCode::Enter, KeyModifiers::NONE, Some(Input::Enter)),
|
||||
(KeyCode::Enter, KeyModifiers::CONTROL, Some(Input::Enter)),
|
||||
(
|
||||
KeyCode::Char('J'),
|
||||
KeyModifiers::SHIFT,
|
||||
Some(Input::Char('J')),
|
||||
),
|
||||
(KeyCode::Char('j'), KeyModifiers::ALT, None),
|
||||
] {
|
||||
assert_eq!(translate(KeyEvent::new(code, modifiers)), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_list_window_follows_control_j_k_selection() {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
editor.handle(Input::Char(' '));
|
||||
editor.handle(Input::Char('o'));
|
||||
let count = editor.file_picker().unwrap().entries().len();
|
||||
assert!(count > 1);
|
||||
for _ in 0..count - 1 {
|
||||
editor.handle(
|
||||
translate(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL)).unwrap(),
|
||||
);
|
||||
}
|
||||
assert_eq!(editor.file_picker().unwrap().selected, count - 1);
|
||||
editor.handle(translate(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)).unwrap());
|
||||
assert_eq!(editor.file_picker().unwrap().selected, count - 2);
|
||||
let picker = editor.file_picker().unwrap();
|
||||
let label = picker.entries().nth(picker.selected).unwrap().label.clone();
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 9)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
draw_file_list(
|
||||
frame,
|
||||
picker,
|
||||
frame.area(),
|
||||
editor.selection_navigation_hint(),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains(&format!("> {label}")), "{text}");
|
||||
let buffer = terminal.backend().buffer();
|
||||
let selected_y = (3..8).find(|&y| buffer[(1, y)].symbol() == ">").unwrap();
|
||||
assert_eq!(buffer[(1, selected_y)].bg, Color::Blue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_footers_show_remapped_and_disabled_selection_keys() {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
for key in ["ctrl+j", "ctrl+n", "down", "ctrl+k", "up"] {
|
||||
editor.bind("selection", key, None).unwrap();
|
||||
}
|
||||
editor
|
||||
.bind("selection", "x", Some("core.down".into()))
|
||||
.unwrap();
|
||||
let hint = editor.selection_navigation_hint().to_owned();
|
||||
assert_eq!(hint, "x next · unbound previous");
|
||||
editor.set_file_explorer_side("bottom").unwrap();
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
for key in ['o', 'e'] {
|
||||
editor.handle(Input::Char(' '));
|
||||
editor.handle(Input::Char(key));
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains(&hint), "{text}");
|
||||
assert!(!text.contains("Ctrl+j"), "{text}");
|
||||
editor.handle(Input::Escape);
|
||||
}
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains(&hint), "{text}");
|
||||
assert!(!text.contains("Ctrl+j"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_list_scrolls_selection_and_sanitizes_display_without_losing_paths() {
|
||||
let directory = Directory::new();
|
||||
@@ -745,6 +853,7 @@ mod tests {
|
||||
directory.file(&format!("file-{index:02}"), "text");
|
||||
}
|
||||
let special = directory.file("界e\u{301} space\nname", "text");
|
||||
let editor = Editor::open(None, vec![]).unwrap();
|
||||
let mut picker = FilePicker::scan(directory.0.clone(), FilePresentation::Floating).unwrap();
|
||||
assert!(
|
||||
picker
|
||||
@@ -765,7 +874,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw_file_list(frame, &picker, area))
|
||||
.draw(|frame| draw_file_list(frame, &picker, area, editor.selection_navigation_hint()))
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
assert!(!text.contains("file-00"), "{text}");
|
||||
@@ -792,11 +901,12 @@ mod tests {
|
||||
#[test]
|
||||
fn file_list_renders_root_empty_filter_errors_and_idle_focus() {
|
||||
let directory = Directory::new();
|
||||
let editor = Editor::open(None, vec![]).unwrap();
|
||||
let mut picker = FilePicker::scan(directory.0.clone(), FilePresentation::Docked).unwrap();
|
||||
let area = Rect::new(0, 0, 100, 12);
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw_file_list(frame, &picker, area))
|
||||
.draw(|frame| draw_file_list(frame, &picker, area, editor.selection_navigation_hint()))
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains("Explorer"), "{text}");
|
||||
@@ -812,7 +922,7 @@ mod tests {
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
frame.set_cursor_position((99, 11));
|
||||
draw_file_list(frame, &picker, area);
|
||||
draw_file_list(frame, &picker, area, editor.selection_navigation_hint());
|
||||
})
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
@@ -828,7 +938,7 @@ mod tests {
|
||||
picker.presentation = FilePresentation::Floating;
|
||||
picker.focused = true;
|
||||
terminal
|
||||
.draw(|frame| draw_file_list(frame, &picker, area))
|
||||
.draw(|frame| draw_file_list(frame, &picker, area, editor.selection_navigation_hint()))
|
||||
.unwrap();
|
||||
assert!(screen_text(&terminal).contains("Files"));
|
||||
assert!(screen_text(&terminal).contains("Esc/^W"));
|
||||
@@ -838,6 +948,7 @@ mod tests {
|
||||
#[test]
|
||||
fn file_query_cursor_and_dock_layout_survive_tiny_offset_resizes() {
|
||||
let directory = Directory::new();
|
||||
let editor = Editor::open(None, vec![]).unwrap();
|
||||
let mut picker = FilePicker::scan(directory.0.clone(), FilePresentation::Docked).unwrap();
|
||||
picker.query = "long界e\u{301}z".into();
|
||||
for (width, height) in [(12, 6), (8, 2), (1, 1), (1, 2), (8, 1), (12, 6)] {
|
||||
@@ -850,7 +961,9 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw_file_list(frame, &picker, area))
|
||||
.draw(|frame| {
|
||||
draw_file_list(frame, &picker, area, editor.selection_navigation_hint())
|
||||
})
|
||||
.unwrap();
|
||||
let cursor = terminal.get_cursor_position().unwrap();
|
||||
assert!(area.contains(cursor), "{area:?}: {cursor:?}");
|
||||
@@ -899,7 +1012,14 @@ mod tests {
|
||||
let mut terminal =
|
||||
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw_file_list(frame, &picker, frame.area()))
|
||||
.draw(|frame| {
|
||||
draw_file_list(
|
||||
frame,
|
||||
&picker,
|
||||
frame.area(),
|
||||
editor.selection_navigation_hint(),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
dock_layout(Rect::new(3, 2, width, height), DockSide::Right, true)
|
||||
@@ -1673,7 +1793,7 @@ mod tests {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
for _ in 0..20 {
|
||||
editor.handle(Input::Down);
|
||||
editor.handle(Input::Ctrl('j'));
|
||||
}
|
||||
editor.handle(Input::Ctrl('k'));
|
||||
editor.handle(Input::Ctrl('n'));
|
||||
@@ -1746,6 +1866,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn palette_query_scrolls_graphemes_tabs_and_wide_cells_on_tiny_offset_areas() {
|
||||
let editor = Editor::open(None, vec![]).unwrap();
|
||||
let palette = Palette {
|
||||
query: "long\t界e\u{301}z".into(),
|
||||
entries: vec![],
|
||||
@@ -1761,7 +1882,14 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw_palette(frame, &palette, Mode::Visual))
|
||||
.draw(|frame| {
|
||||
draw_palette(
|
||||
frame,
|
||||
&palette,
|
||||
Mode::Visual,
|
||||
editor.selection_navigation_hint(),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let cursor = terminal.get_cursor_position().unwrap();
|
||||
assert!(area.contains(cursor), "{area:?}: {cursor:?}");
|
||||
@@ -1795,7 +1923,14 @@ mod tests {
|
||||
let mut terminal =
|
||||
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw_palette(frame, &palette, Mode::Normal))
|
||||
.draw(|frame| {
|
||||
draw_palette(
|
||||
frame,
|
||||
&palette,
|
||||
Mode::Normal,
|
||||
editor.selection_navigation_hint(),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -2080,29 +2215,75 @@ mod tests {
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn terminal_session_scopes_the_callers_panic_hook() {
|
||||
use std::io::Write;
|
||||
const CHILD: &str = "VAMP_TERMINAL_HOOK_PROBE";
|
||||
if let Ok(mode) = std::env::var(CHILD) {
|
||||
std::panic::set_hook(Box::new(|_| {
|
||||
std::panic::set_hook(Box::new(|info| {
|
||||
assert!(!crossterm::terminal::is_raw_mode_enabled().unwrap());
|
||||
println!("CALLER_HOOK");
|
||||
eprintln!("{info}");
|
||||
}));
|
||||
if mode == "entry" {
|
||||
let mut editor = EditorSession::open(None, vec![], None).unwrap();
|
||||
assert!(run(&mut editor).is_err());
|
||||
} else {
|
||||
let result = panic::catch_unwind(|| {
|
||||
let result = panic::catch_unwind(AssertUnwindSafe(|| {
|
||||
TerminalSession::run(|| {
|
||||
assert!(crossterm::terminal::is_raw_mode_enabled().unwrap());
|
||||
match mode.as_str() {
|
||||
"normal" => Ok(()),
|
||||
"error" => Err(io::Error::from(io::ErrorKind::BrokenPipe)),
|
||||
"panic" => panic::panic_any(73_u64),
|
||||
"keys" => {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
let original = editor.lines().to_vec();
|
||||
println!("READING_KEYS");
|
||||
io::stdout().flush()?;
|
||||
for (index, expected) in [
|
||||
Input::Ctrl('j'),
|
||||
Input::Ctrl('k'),
|
||||
Input::Ctrl('j'),
|
||||
Input::Ctrl('k'),
|
||||
Input::Enter,
|
||||
Input::Enter,
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let Event::Key(key) = event::read()? else {
|
||||
panic!("expected a keyboard event");
|
||||
};
|
||||
let input = translate(key).unwrap();
|
||||
assert_eq!(input, expected);
|
||||
if index == 4 {
|
||||
for ch in "q!".chars() {
|
||||
editor.handle(Input::Char(ch));
|
||||
}
|
||||
}
|
||||
editor.handle(input);
|
||||
match index {
|
||||
0 | 2 => assert_eq!(editor.palette().unwrap().selected, 1),
|
||||
1 | 3 => assert_eq!(editor.palette().unwrap().selected, 0),
|
||||
4 => {
|
||||
assert!(editor.palette().is_none());
|
||||
assert_eq!(editor.mode(), Mode::Command);
|
||||
assert_eq!(editor.command_line(), "q! ");
|
||||
assert!(!editor.should_quit());
|
||||
}
|
||||
5 => assert!(editor.should_quit()),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert_eq!(editor.lines(), original);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
})
|
||||
});
|
||||
}));
|
||||
match mode.as_str() {
|
||||
"normal" => result.unwrap().unwrap(),
|
||||
"normal" | "keys" => result.unwrap().unwrap(),
|
||||
"error" => assert_eq!(
|
||||
result.unwrap().unwrap_err().kind(),
|
||||
io::ErrorKind::BrokenPipe
|
||||
@@ -2144,32 +2325,68 @@ mod tests {
|
||||
}
|
||||
});
|
||||
}
|
||||
let check = |output: std::process::Output| {
|
||||
assert!(output.status.success(), "{output:?}");
|
||||
let check = |output: std::process::Output, mode: &str| {
|
||||
assert!(output.status.success(), "{mode}: {output:?}");
|
||||
let stdout = String::from_utf8(output.stdout).unwrap();
|
||||
let after = stdout.split_once("AFTER_TERMINAL_RETURN").unwrap().1;
|
||||
let (before, after) = stdout.split_once("AFTER_TERMINAL_RETURN").unwrap();
|
||||
let entered = usize::from(mode != "entry");
|
||||
assert_eq!(
|
||||
before.matches("\x1b[?1049h").count(),
|
||||
entered,
|
||||
"{mode}: {before:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
before.matches("\x1b[?1049l").count(),
|
||||
entered,
|
||||
"{mode}: {before:?}"
|
||||
);
|
||||
if mode == "panic" {
|
||||
assert!(
|
||||
before.find("\x1b[?1049l").unwrap() < before.find("CALLER_HOOK").unwrap(),
|
||||
"{before:?}",
|
||||
);
|
||||
}
|
||||
assert!(after.contains("CALLER_HOOK"), "{after:?}");
|
||||
assert!(
|
||||
!after.contains('\u{1b}'),
|
||||
"terminal hook outlived its session: {after:?}"
|
||||
);
|
||||
};
|
||||
check(command.output().unwrap());
|
||||
check(command.output().unwrap(), "entry");
|
||||
|
||||
// Ruby's standard-library PTY gives each positive-entry scenario a real
|
||||
// terminal, keeping process-global hooks isolated from the test runner.
|
||||
for mode in ["normal", "error", "panic"] {
|
||||
for mode in ["normal", "error", "panic", "keys"] {
|
||||
let output =
|
||||
Command::new(std::env::var_os("VAMP_RUBY").unwrap_or_else(|| "ruby".into()))
|
||||
.args([
|
||||
"-rpty",
|
||||
"-rtimeout",
|
||||
"-e",
|
||||
r#"
|
||||
PTY.spawn(*ARGV) do |output, input, pid|
|
||||
input.close
|
||||
sent_keys = false
|
||||
captured = +""
|
||||
begin
|
||||
loop { STDOUT.write(output.readpartial(4096)) }
|
||||
Timeout.timeout(10) do
|
||||
loop do
|
||||
chunk = output.readpartial(4096)
|
||||
STDOUT.write(chunk)
|
||||
captured << chunk
|
||||
if !sent_keys && captured.include?("READING_KEYS")
|
||||
input.write("\e[106;5u\e[107;5u\n\v\e[13u\r")
|
||||
input.flush
|
||||
sent_keys = true
|
||||
end
|
||||
end
|
||||
end
|
||||
rescue EOFError, Errno::EIO
|
||||
rescue Timeout::Error
|
||||
Process.kill("TERM", pid)
|
||||
Process.wait(pid)
|
||||
abort "terminal lifecycle probe timed out"
|
||||
ensure
|
||||
input.close
|
||||
end
|
||||
Process.wait(pid)
|
||||
exit($?.exitstatus || 1)
|
||||
@@ -2188,7 +2405,7 @@ end
|
||||
.env(CHILD, mode)
|
||||
.output()
|
||||
.unwrap();
|
||||
check(output);
|
||||
check(output, mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user