feat(app): vi-style j/k/g/G navigation in the load picker (#24)
Add j (down), k (up), g (first) and G (last) to the load picker's list sub-mode, alongside the existing arrow keys. Typeable keys keep the picker drivable by autocast in the website's documentation casts, which cannot emit arrow keys. Footer hint left unchanged.
This commit is contained in:
+16
-2
@@ -2488,20 +2488,34 @@ impl App {
|
||||
self.note_system(crate::t!("modal.load_cancelled"));
|
||||
Vec::new()
|
||||
}
|
||||
KeyCode::Up => {
|
||||
// `k` mirrors Up; vi-style keys keep the picker drivable by
|
||||
// autocast, which can only emit typeable characters (#24).
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if state.selected > 0 {
|
||||
state.selected -= 1;
|
||||
}
|
||||
self.modal = Some(Modal::LoadPicker(state));
|
||||
Vec::new()
|
||||
}
|
||||
KeyCode::Down => {
|
||||
// `j` mirrors Down (see the Up arm above).
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if state.selected + 1 < state.entries.len() {
|
||||
state.selected += 1;
|
||||
}
|
||||
self.modal = Some(Modal::LoadPicker(state));
|
||||
Vec::new()
|
||||
}
|
||||
// `g` jumps to the first entry, `G` to the last (vi convention).
|
||||
KeyCode::Char('g') => {
|
||||
state.selected = 0;
|
||||
self.modal = Some(Modal::LoadPicker(state));
|
||||
Vec::new()
|
||||
}
|
||||
KeyCode::Char('G') => {
|
||||
state.selected = state.entries.len().saturating_sub(1);
|
||||
self.modal = Some(Modal::LoadPicker(state));
|
||||
Vec::new()
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(entry) = state.entries.get(state.selected).cloned() {
|
||||
self.modal = None;
|
||||
|
||||
@@ -252,6 +252,91 @@ fn load_picker_renders_entries_and_navigates() {
|
||||
assert_eq!(source, "load");
|
||||
}
|
||||
|
||||
/// Build a load picker with three entries for the vi-navigation tests.
|
||||
fn three_entry_picker() -> App {
|
||||
let mut app = App::new();
|
||||
app.update(AppEvent::LoadPickerReady {
|
||||
entries: vec![
|
||||
LoadPickerEntry {
|
||||
display_name: "First".to_string(),
|
||||
modified: "2026-05-07 14:30".to_string(),
|
||||
path: std::path::PathBuf::from("/tmp/first"),
|
||||
is_temp: true,
|
||||
},
|
||||
LoadPickerEntry {
|
||||
display_name: "Second".to_string(),
|
||||
modified: "2026-05-05 10:00".to_string(),
|
||||
path: std::path::PathBuf::from("/tmp/second"),
|
||||
is_temp: false,
|
||||
},
|
||||
LoadPickerEntry {
|
||||
display_name: "Third".to_string(),
|
||||
modified: "2026-05-01 09:15".to_string(),
|
||||
path: std::path::PathBuf::from("/tmp/third"),
|
||||
is_temp: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
app
|
||||
}
|
||||
|
||||
fn picker_selected(app: &App) -> usize {
|
||||
let Some(Modal::LoadPicker(picker)) = app.modal.as_ref() else {
|
||||
panic!("expected LoadPicker modal");
|
||||
};
|
||||
picker.selected
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_picker_jk_navigates_like_arrows() {
|
||||
// vi-style j/k mirror Down/Up so autocast (typeable keys only) can drive
|
||||
// the load picker in documentation casts (#24).
|
||||
let mut app = three_entry_picker();
|
||||
assert_eq!(picker_selected(&app), 0);
|
||||
|
||||
// j moves the selection down.
|
||||
app.update(key(KeyCode::Char('j')));
|
||||
assert_eq!(picker_selected(&app), 1);
|
||||
app.update(key(KeyCode::Char('j')));
|
||||
assert_eq!(picker_selected(&app), 2);
|
||||
|
||||
// j at the last entry does not wrap past the end.
|
||||
app.update(key(KeyCode::Char('j')));
|
||||
assert_eq!(picker_selected(&app), 2);
|
||||
|
||||
// k moves the selection up.
|
||||
app.update(key(KeyCode::Char('k')));
|
||||
assert_eq!(picker_selected(&app), 1);
|
||||
|
||||
// k at the first entry does not wrap past the start.
|
||||
app.update(key(KeyCode::Char('k')));
|
||||
assert_eq!(picker_selected(&app), 0);
|
||||
app.update(key(KeyCode::Char('k')));
|
||||
assert_eq!(picker_selected(&app), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_picker_g_jumps_to_first_and_last() {
|
||||
// g → first entry, G → last entry (vi convention).
|
||||
let mut app = three_entry_picker();
|
||||
|
||||
// G jumps to the last entry from the top.
|
||||
app.update(key(KeyCode::Char('G')));
|
||||
assert_eq!(picker_selected(&app), 2);
|
||||
|
||||
// G again is idempotent at the end.
|
||||
app.update(key(KeyCode::Char('G')));
|
||||
assert_eq!(picker_selected(&app), 2);
|
||||
|
||||
// g jumps back to the first entry.
|
||||
app.update(key(KeyCode::Char('g')));
|
||||
assert_eq!(picker_selected(&app), 0);
|
||||
|
||||
// g again is idempotent at the start.
|
||||
app.update(key(KeyCode::Char('g')));
|
||||
assert_eq!(picker_selected(&app), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_picker_b_enters_path_entry_submode() {
|
||||
let mut app = App::new();
|
||||
|
||||
Reference in New Issue
Block a user