aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
authorcorrius <alvaro.correa@taxdown.es>2026-07-24 21:52:17 +0200
committerGitHub <noreply@github.com>2026-07-24 22:52:17 +0300
commit38d2b0780c50e4773d6948660873386261cde71d (patch)
treeb899dd7ce1dfd1a70650be0209a48132c1539a25 /src/app
parente9b22084e03880f43d1f1daaf5d9f45c717b77c4 (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')
-rw-r--r--src/app/input/mod.rs34
-rw-r--r--src/app/input/modal.rs204
-rw-r--r--src/app/input/overlays.rs31
-rw-r--r--src/app/mod.rs4
-rw-r--r--src/app/state.rs10
5 files changed, 272 insertions, 11 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;
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,