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:
claude@clouddev1
2026-06-10 21:36:18 +00:00
parent 18303784a0
commit 638b4c9664
2 changed files with 101 additions and 2 deletions
+16 -2
View File
@@ -2488,20 +2488,34 @@ impl App {
self.note_system(crate::t!("modal.load_cancelled")); self.note_system(crate::t!("modal.load_cancelled"));
Vec::new() 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 { if state.selected > 0 {
state.selected -= 1; state.selected -= 1;
} }
self.modal = Some(Modal::LoadPicker(state)); self.modal = Some(Modal::LoadPicker(state));
Vec::new() Vec::new()
} }
KeyCode::Down => { // `j` mirrors Down (see the Up arm above).
KeyCode::Down | KeyCode::Char('j') => {
if state.selected + 1 < state.entries.len() { if state.selected + 1 < state.entries.len() {
state.selected += 1; state.selected += 1;
} }
self.modal = Some(Modal::LoadPicker(state)); self.modal = Some(Modal::LoadPicker(state));
Vec::new() 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 => { KeyCode::Enter => {
if let Some(entry) = state.entries.get(state.selected).cloned() { if let Some(entry) = state.entries.get(state.selected).cloned() {
self.modal = None; self.modal = None;
@@ -252,6 +252,91 @@ fn load_picker_renders_entries_and_navigates() {
assert_eq!(source, "load"); 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] #[test]
fn load_picker_b_enters_path_entry_submode() { fn load_picker_b_enters_path_entry_submode() {
let mut app = App::new(); let mut app = App::new();