diff options
| author | corrius <alvaro.correa@taxdown.es> | 2026-07-24 21:52:17 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-07-24 22:52:17 +0300 |
| commit | 38d2b0780c50e4773d6948660873386261cde71d (patch) | |
| tree | b899dd7ce1dfd1a70650be0209a48132c1539a25 /src/app/input | |
| parent | e9b22084e03880f43d1f1daaf5d9f45c717b77c4 (diff) | |
feat: filter keybind help (#1832)
* feat: filter keybind help
refs #1825
* fix: recognize shifted slash in keybind help
---------
Co-authored-by: Ogulcan Celik <ogulcancelik@gmail.com>
Diffstat (limited to 'src/app/input')
| -rw-r--r-- | src/app/input/mod.rs | 34 | ||||
| -rw-r--r-- | src/app/input/modal.rs | 204 | ||||
| -rw-r--r-- | src/app/input/overlays.rs | 31 |
3 files changed, 263 insertions, 6 deletions
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 16713222..f304cde6 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -49,7 +49,8 @@ mod terminal; pub(crate) use self::{ modal::{ handle_global_menu_key, handle_keybind_help_key, handle_navigator_key, - insert_navigator_search_text, insert_rename_input_text, open_new_workspace_dialog, + insert_keybind_help_query_text, insert_navigator_search_text, insert_rename_input_text, + open_new_workspace_dialog, }, navigate::{ terminal_direct_indexed_navigation_action, terminal_direct_non_indexed_navigation_action, @@ -109,7 +110,7 @@ impl App { } Mode::Settings => self.handle_settings_key(key_event), Mode::GlobalMenu => handle_global_menu_key(&mut self.state, key_event), - Mode::KeybindHelp => handle_keybind_help_key(&mut self.state, key_event), + Mode::KeybindHelp => handle_keybind_help_key(&mut self.state, key), Mode::Navigator => { handle_navigator_key(&mut self.state, &self.terminal_runtimes, key_event) } @@ -172,6 +173,13 @@ impl App { insert_navigator_search_text(&mut self.state, &self.terminal_runtimes, text); true } + Mode::KeybindHelp => { + if !self.state.keybind_help.search_focused { + return false; + } + insert_keybind_help_query_text(&mut self.state, text); + true + } Mode::Copy => { let Some(prompt) = self .state @@ -653,6 +661,7 @@ pub(crate) fn modal_paste_target_active(state: &AppState) -> bool { .as_ref() .is_some_and(|open| open.search_focused), Mode::Navigator => state.navigator.search_focused, + Mode::KeybindHelp => state.keybind_help.search_focused, Mode::Copy => state .copy_mode .as_ref() @@ -858,6 +867,21 @@ mod tests { } #[tokio::test] + async fn paste_routes_to_keybind_help_query_only_when_searching() { + let mut app = test_app(); + app.state.mode = Mode::KeybindHelp; + app.handle_paste("ignored".into()).await; + assert!(app.state.keybind_help.query.is_empty()); + + app.state.keybind_help.search_focused = true; + app.state.keybind_help.scroll = 3; + app.handle_paste("work\nspace".into()).await; + + assert_eq!(app.state.keybind_help.query, "workspace"); + assert_eq!(app.state.keybind_help.scroll, 0); + } + + #[tokio::test] async fn paste_routes_to_new_linked_worktree_input() { let mut app = test_app(); app.state.mode = Mode::NewLinkedWorktree; @@ -922,6 +946,12 @@ mod tests { state.navigator.search_focused = true; assert!(modal_paste_target_active(&state)); + state.mode = Mode::KeybindHelp; + state.keybind_help.search_focused = false; + assert!(!modal_paste_target_active(&state)); + state.keybind_help.search_focused = true; + assert!(modal_paste_target_active(&state)); + state.mode = Mode::ConfirmClose; assert!(!modal_paste_target_active(&state)); } diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs index dc126b40..d8a14602 100644 --- a/src/app/input/modal.rs +++ b/src/app/input/modal.rs @@ -101,6 +101,8 @@ pub(super) fn open_global_menu(state: &mut AppState) { pub(super) fn open_keybind_help(state: &mut AppState) { state.keybind_help.scroll = 0; + state.keybind_help.query.clear(); + state.keybind_help.search_focused = false; state.mode = Mode::KeybindHelp; } @@ -284,7 +286,56 @@ pub(crate) fn insert_navigator_search_text( state.select_first_navigator_match_from(terminal_runtimes); } -pub(crate) fn handle_keybind_help_key(state: &mut AppState, key: KeyEvent) { +pub(crate) fn insert_keybind_help_query_text(state: &mut AppState, text: &str) { + if !state.keybind_help.search_focused { + return; + } + state + .keybind_help + .query + .extend(text.chars().filter(|ch| !ch.is_control())); + state.keybind_help.scroll = 0; +} + +pub(super) fn keybind_help_back(state: &mut AppState) { + if state.keybind_help.search_focused { + state.keybind_help.query.clear(); + state.keybind_help.search_focused = false; + state.keybind_help.scroll = 0; + } else { + leave_modal(state); + } +} + +pub(crate) fn handle_keybind_help_key(state: &mut AppState, key: TerminalKey) { + if state.keybind_help.search_focused { + let text_char = keybind_help_text_char(key); + match key.code { + KeyCode::Up => state.scroll_keybind_help(-1), + KeyCode::Down => state.scroll_keybind_help(1), + KeyCode::PageUp => state.scroll_keybind_help(-8), + KeyCode::PageDown => state.scroll_keybind_help(8), + KeyCode::Home => state.keybind_help.scroll = 0, + KeyCode::End => state.keybind_help.scroll = state.keybind_help_max_scroll(), + KeyCode::Backspace => { + state.keybind_help.query.pop(); + state.keybind_help.scroll = 0; + } + KeyCode::Char('u') if key.modifiers == KeyModifiers::CONTROL => { + state.keybind_help.query.clear(); + state.keybind_help.scroll = 0; + } + KeyCode::Esc => keybind_help_back(state), + KeyCode::Enter => leave_modal(state), + _ => { + if let Some(character) = text_char { + insert_keybind_help_query_text(state, &character.to_string()); + } + } + } + return; + } + match key.code { KeyCode::Up | KeyCode::Char('k') => state.scroll_keybind_help(-1), KeyCode::Down | KeyCode::Char('j') => state.scroll_keybind_help(1), @@ -292,11 +343,30 @@ pub(crate) fn handle_keybind_help_key(state: &mut AppState, key: KeyEvent) { KeyCode::PageDown => state.scroll_keybind_help(8), KeyCode::Home => state.keybind_help.scroll = 0, KeyCode::End => state.keybind_help.scroll = state.keybind_help_max_scroll(), - KeyCode::Esc | KeyCode::Enter | KeyCode::Char('?') => leave_modal(state), + _ if keybind_help_text_char(key) == Some('/') => { + state.keybind_help.search_focused = true; + state.keybind_help.scroll = 0; + } + KeyCode::Esc => keybind_help_back(state), + KeyCode::Enter => leave_modal(state), + _ if keybind_help_text_char(key) == Some('?') => leave_modal(state), _ => {} } } +fn keybind_help_text_char(key: TerminalKey) -> Option<char> { + if !key.modifiers.difference(KeyModifiers::SHIFT).is_empty() { + return None; + } + if let Some(character) = key.shifted_codepoint.and_then(char::from_u32) { + return Some(character); + } + let KeyCode::Char(character) = key.code else { + return None; + }; + Some(character) +} + pub(super) fn open_rename_workspace( state: &mut AppState, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, @@ -1655,6 +1725,136 @@ mod tests { } #[test] + fn keybind_help_slash_focuses_filter_and_preserves_vim_scroll() { + let mut state = state_with_workspaces(&["test"]); + state.keybind_help.query = "stale".into(); + state.keybind_help.search_focused = true; + state.view.terminal_area = Rect::new(0, 0, 100, 30); + + open_keybind_help(&mut state); + handle_keybind_help_key( + &mut state, + TerminalKey::new(KeyCode::Char('j'), KeyModifiers::empty()), + ); + assert_eq!(state.keybind_help.scroll, 1); + handle_keybind_help_key( + &mut state, + TerminalKey::new(KeyCode::Char('k'), KeyModifiers::empty()), + ); + assert_eq!(state.keybind_help.scroll, 0); + + handle_keybind_help_key( + &mut state, + TerminalKey::new(KeyCode::Char('w'), KeyModifiers::empty()), + ); + assert!(state.keybind_help.query.is_empty()); + + handle_keybind_help_key( + &mut state, + TerminalKey::new(KeyCode::Char('/'), KeyModifiers::empty()), + ); + for character in "work".chars() { + state.keybind_help.scroll = 2; + handle_keybind_help_key( + &mut state, + TerminalKey::new(KeyCode::Char(character), KeyModifiers::empty()), + ); + } + + assert!(state.keybind_help.search_focused); + assert_eq!(state.keybind_help.query, "work"); + assert_eq!(state.keybind_help.scroll, 0); + } + + #[test] + fn keybind_help_query_supports_backspace_clear_and_sanitized_paste() { + let mut state = state_with_workspaces(&["test"]); + open_keybind_help(&mut state); + handle_keybind_help_key( + &mut state, + TerminalKey::new(KeyCode::Char('/'), KeyModifiers::empty()), + ); + + insert_keybind_help_query_text(&mut state, "work\nspace"); + assert_eq!(state.keybind_help.query, "workspace"); + + handle_keybind_help_key( + &mut state, + TerminalKey::new(KeyCode::Backspace, KeyModifiers::empty()), + ); + assert_eq!(state.keybind_help.query, "workspac"); + + handle_keybind_help_key( + &mut state, + TerminalKey::new(KeyCode::Char('u'), KeyModifiers::CONTROL), + ); + assert!(state.keybind_help.query.is_empty()); + } + + #[test] + fn keybind_help_escape_leaves_search_before_closing() { + let mut state = state_with_workspaces(&["test"]); + open_keybind_help(&mut state); + state.keybind_help.search_focused = true; + state.keybind_help.query = "work".into(); + + handle_keybind_help_key( + &mut state, + TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()), + ); + assert_eq!(state.mode, Mode::KeybindHelp); + assert!(!state.keybind_help.search_focused); + assert!(state.keybind_help.query.is_empty()); + + handle_keybind_help_key( + &mut state, + TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()), + ); + assert_eq!(state.mode, Mode::Terminal); + } + + #[test] + fn enhanced_shifted_slash_focuses_keybind_help_filter() { + let mut state = state_with_workspaces(&["test"]); + open_keybind_help(&mut state); + + handle_keybind_help_key( + &mut state, + TerminalKey::new(KeyCode::Char('7'), KeyModifiers::SHIFT) + .with_shifted_codepoint('/' as u32), + ); + + assert!(state.keybind_help.search_focused); + } + + #[test] + fn enhanced_shifted_question_mark_closes_keybind_help_when_not_searching() { + let mut state = state_with_workspaces(&["test"]); + open_keybind_help(&mut state); + + handle_keybind_help_key( + &mut state, + TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) + .with_shifted_codepoint('?' as u32), + ); + + assert_eq!(state.mode, Mode::Terminal); + + open_keybind_help(&mut state); + handle_keybind_help_key( + &mut state, + TerminalKey::new(KeyCode::Char('/'), KeyModifiers::empty()), + ); + handle_keybind_help_key( + &mut state, + TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) + .with_shifted_codepoint('?' as u32), + ); + + assert_eq!(state.keybind_help.query, "?"); + } + + #[test] fn navigator_search_accepts_pasted_text_when_focused() { let mut state = state_with_workspaces(&["alpha", "beta"]); let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); diff --git a/src/app/input/overlays.rs b/src/app/input/overlays.rs index e9cf3139..58f2ef71 100644 --- a/src/app/input/overlays.rs +++ b/src/app/input/overlays.rs @@ -10,7 +10,7 @@ use crate::app::{ }; use super::{ - modal::{leave_modal, modal_action_from_buttons, ModalAction}, + modal::{keybind_help_back, leave_modal, modal_action_from_buttons, ModalAction}, ScrollbarClickTarget, }; @@ -197,7 +197,7 @@ impl App { .state .keybind_help_close_button_at(mouse.column, mouse.row) => { - leave_modal(&mut self.state); + keybind_help_back(&mut self.state); } MouseEventKind::Down(MouseButton::Left) => { if let Some(target) = self @@ -739,6 +739,33 @@ mod tests { } #[test] + fn clicking_keybind_help_back_button_leaves_help_open() { + let mut app = app_for_mouse_test(); + app.state.mode = Mode::KeybindHelp; + app.state.keybind_help.search_focused = true; + app.state.keybind_help.query = "work".into(); + + let rect = app.state.keybind_help_popup_rect(); + let inner = Rect::new( + rect.x + 1, + rect.y + 1, + rect.width.saturating_sub(2), + rect.height.saturating_sub(2), + ); + let back = + crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1)); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + back.x, + back.y, + )); + + assert_eq!(app.state.mode, Mode::KeybindHelp); + assert!(!app.state.keybind_help.search_focused); + assert!(app.state.keybind_help.query.is_empty()); + } + + #[test] fn onboarding_hover_does_not_change_selection() { let mut app = app_for_mouse_test(); app.state.mode = Mode::Onboarding; |
