From 38d2b0780c50e4773d6948660873386261cde71d Mon Sep 17 00:00:00 2001 From: corrius Date: Fri, 24 Jul 2026 21:52:17 +0200 Subject: feat: filter keybind help (#1832) * feat: filter keybind help refs #1825 * fix: recognize shifted slash in keybind help --------- Co-authored-by: Ogulcan Celik --- src/app/input/mod.rs | 34 +++++++- src/app/input/modal.rs | 204 +++++++++++++++++++++++++++++++++++++++++++++- src/app/input/overlays.rs | 31 ++++++- src/app/mod.rs | 4 +- src/app/state.rs | 10 ++- src/ui/keybind_help.rs | 152 +++++++++++++++++++++++++++++----- 6 files changed, 404 insertions(+), 31 deletions(-) (limited to 'src') 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() @@ -857,6 +866,21 @@ mod tests { assert!(!app.state.name_input_replace_on_type); } + #[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(); @@ -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 { + 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, @@ -1654,6 +1724,136 @@ mod tests { assert_eq!(state.name_input, "websiteZ"); } + #[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"]); 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 @@ -738,6 +738,33 @@ mod tests { assert_eq!(app.state.mode, Mode::Navigate); } + #[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(); diff --git a/src/app/mod.rs b/src/app/mod.rs index 30e1c949..6ca1d940 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -573,7 +573,7 @@ impl App { preview: announcement.preview, } }), - keybind_help: state::KeybindHelpState { scroll: 0 }, + keybind_help: state::KeybindHelpState::default(), navigator: state::NavigatorState::default(), copy_mode: None, workspace_scroll: 0, @@ -1771,7 +1771,7 @@ impl App { self.handle_context_menu_key_via_api(key_event); } Mode::KeybindHelp => { - input::handle_keybind_help_key(&mut self.state, key_event); + input::handle_keybind_help_key(&mut self.state, key); } Mode::GlobalMenu => { input::handle_global_menu_key(&mut self.state, key_event); diff --git a/src/app/state.rs b/src/app/state.rs index f8395cbd..b9e4ed70 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -821,8 +821,9 @@ impl Mode { /// `sync_prefix_input_source` (gated by `switch_ascii_input_source_in_prefix`) so multi-level /// prefix commands keep ASCII until they return to the terminal. /// - /// Known limitation: `Navigator`'s search box is also held on ASCII, since this `Mode`-level - /// predicate can't see `search_focused` (non-ASCII filtering there would need a runtime check). + /// Known limitation: the search boxes in `Navigator` and `KeybindHelp` are also held on ASCII, + /// since this `Mode`-level predicate can't see `search_focused` (non-ASCII filtering there + /// would need a runtime check). pub(crate) fn wants_ascii_input(self) -> bool { matches!( self, @@ -1381,8 +1382,11 @@ pub struct ProductAnnouncementState { pub preview: bool, } +#[derive(Default)] pub struct KeybindHelpState { pub scroll: u16, + pub query: String, + pub search_focused: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -1818,7 +1822,7 @@ impl AppState { name_input_replace_on_type: false, release_notes: None, product_announcement: None, - keybind_help: KeybindHelpState { scroll: 0 }, + keybind_help: KeybindHelpState::default(), navigator: NavigatorState::default(), copy_mode: None, workspace_scroll: 0, diff --git a/src/ui/keybind_help.rs b/src/ui/keybind_help.rs index 8385af36..25c999fa 100644 --- a/src/ui/keybind_help.rs +++ b/src/ui/keybind_help.rs @@ -181,6 +181,26 @@ pub(super) fn keybind_help_groups(app: &AppState) -> Vec { groups } +fn filter_keybind_help_groups(groups: Vec, query: &str) -> Vec { + if query.is_empty() { + return groups; + } + + let query = query.to_lowercase(); + groups + .into_iter() + .filter_map(|(group, entries)| { + let entries = entries + .into_iter() + .filter(|(key, label)| { + key.to_lowercase().contains(&query) || label.to_lowercase().contains(&query) + }) + .collect::>(); + (!entries.is_empty()).then_some((group, entries)) + }) + .collect() +} + pub(crate) fn keybind_help_lines(app: &AppState) -> Vec<(usize, Line<'static>)> { let heading_style = Style::default() .fg(app.palette.accent) @@ -190,7 +210,7 @@ pub(crate) fn keybind_help_lines(app: &AppState) -> Vec<(usize, Line<'static>)> .add_modifier(Modifier::BOLD); let label_style = Style::default().fg(app.palette.text); - let groups = keybind_help_groups(app); + let groups = filter_keybind_help_groups(keybind_help_groups(app), &app.keybind_help.query); let key_width = groups .iter() .flat_map(|(_, entries)| entries.iter().map(|(key, _)| key.chars().count())) @@ -199,6 +219,17 @@ pub(crate) fn keybind_help_lines(app: &AppState) -> Vec<(usize, Line<'static>)> let mut lines = Vec::new(); + if groups.is_empty() { + let message = " no matching keybinds"; + return vec![( + message.chars().count(), + Line::from(Span::styled( + message, + Style::default().fg(app.palette.overlay1), + )), + )]; + } + for (group, entries) in groups { lines.push(( group.len() + 1, @@ -240,17 +271,38 @@ pub(super) fn render_keybind_help_overlay(app: &AppState, frame: &mut Frame) { frame, release_notes_close_button_rect(header_rows[0]), Some("esc"), - "close", + if app.keybind_help.search_focused { + "back" + } else { + "close" + }, Style::default() .fg(panel_contrast_fg(&app.palette)) .bg(app.palette.accent) .add_modifier(Modifier::BOLD), ); - frame.render_widget( - Paragraph::new(" available commands and configured shortcuts") - .style(Style::default().fg(app.palette.overlay1)), - header_rows[1], - ); + let search_line = if app.keybind_help.search_focused { + Line::from(vec![ + Span::styled( + " / ", + Style::default() + .fg(app.palette.accent) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + app.keybind_help.query.as_str(), + Style::default() + .fg(app.palette.text) + .add_modifier(Modifier::BOLD), + ), + ]) + } else { + Line::from(Span::styled( + " press / to filter by command or shortcut", + Style::default().fg(app.palette.overlay0), + )) + }; + frame.render_widget(Paragraph::new(search_line), header_rows[1]); let body_area = stack.content; let metrics = crate::pane::ScrollMetrics { @@ -292,17 +344,77 @@ pub(super) fn render_keybind_help_overlay(app: &AppState, frame: &mut Frame) { ); } - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled(" scroll ", Style::default().fg(app.palette.overlay0)), - Span::styled("wheel ↑↓", Style::default().fg(app.palette.text)), - Span::styled(" · ", Style::default().fg(app.palette.overlay0)), - Span::styled("jump", Style::default().fg(app.palette.overlay0)), - Span::styled(" pgup / pgdn ", Style::default().fg(app.palette.text)), - Span::styled(" · ", Style::default().fg(app.palette.overlay0)), - Span::styled("close", Style::default().fg(app.palette.overlay0)), - Span::styled(" esc / enter ", Style::default().fg(app.palette.text)), - ])), - stack.footer.unwrap_or_default(), - ); + let footer = if app.keybind_help.search_focused { + Line::from(vec![ + Span::styled(" filter ", Style::default().fg(app.palette.overlay0)), + Span::styled("type/backspace", Style::default().fg(app.palette.text)), + Span::styled(" · ", Style::default().fg(app.palette.overlay0)), + Span::styled("clear ", Style::default().fg(app.palette.overlay0)), + Span::styled("ctrl+u", Style::default().fg(app.palette.text)), + Span::styled(" · ", Style::default().fg(app.palette.overlay0)), + Span::styled("scroll ", Style::default().fg(app.palette.overlay0)), + Span::styled("↑↓/pgup/pgdn", Style::default().fg(app.palette.text)), + Span::styled(" · ", Style::default().fg(app.palette.overlay0)), + Span::styled("back ", Style::default().fg(app.palette.overlay0)), + Span::styled("esc", Style::default().fg(app.palette.text)), + ]) + } else { + Line::from(vec![ + Span::styled(" search ", Style::default().fg(app.palette.overlay0)), + Span::styled("/", Style::default().fg(app.palette.text)), + Span::styled(" · ", Style::default().fg(app.palette.overlay0)), + Span::styled("scroll ", Style::default().fg(app.palette.overlay0)), + Span::styled("j/k/↑↓/pgup/pgdn", Style::default().fg(app.palette.text)), + Span::styled(" · ", Style::default().fg(app.palette.overlay0)), + Span::styled("close ", Style::default().fg(app.palette.overlay0)), + Span::styled("esc/enter", Style::default().fg(app.palette.text)), + ]) + }; + frame.render_widget(Paragraph::new(footer), stack.footer.unwrap_or_default()); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn groups() -> Vec { + vec![ + ( + "workspaces / tabs", + vec![ + help_entry("w", "workspace navigation"), + help_entry("c", "new tab"), + ], + ), + ( + "panes", + vec![ + help_entry("v", "split vertical"), + help_entry("x", "close pane"), + ], + ), + ] + } + + #[test] + fn keybind_help_filter_matches_labels_case_insensitively() { + let filtered = filter_keybind_help_groups(groups(), "WoRk"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].0, "workspaces / tabs"); + assert_eq!(filtered[0].1.len(), 1); + assert_eq!(filtered[0].1[0].1, "workspace navigation"); + } + + #[test] + fn keybind_help_filter_matches_shortcuts_without_matching_group_headings() { + let filtered = filter_keybind_help_groups(groups(), "x"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].0, "panes"); + assert_eq!(filtered[0].1.len(), 1); + assert_eq!(filtered[0].1[0].1, "close pane"); + + assert!(filter_keybind_help_groups(groups(), "panes").is_empty()); + } } -- cgit v1.2.3