diff --git a/src/app.rs b/src/app.rs index 69d3c2e..6747172 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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; diff --git a/tests/it/iteration4b_lifecycle_commands.rs b/tests/it/iteration4b_lifecycle_commands.rs index a323799..bcd15dd 100644 --- a/tests/it/iteration4b_lifecycle_commands.rs +++ b/tests/it/iteration4b_lifecycle_commands.rs @@ -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();