aboutsummaryrefslogtreecommitdiff
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
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>
-rw-r--r--docs/next/website/src/content/docs/ja/keyboard.mdx2
-rw-r--r--docs/next/website/src/content/docs/keyboard.mdx2
-rw-r--r--docs/next/website/src/content/docs/zh-cn/keyboard.mdx2
-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
-rw-r--r--src/ui/keybind_help.rs152
9 files changed, 407 insertions, 34 deletions
diff --git a/docs/next/website/src/content/docs/ja/keyboard.mdx b/docs/next/website/src/content/docs/ja/keyboard.mdx
index f9bb4ac8..24b554c8 100644
--- a/docs/next/website/src/content/docs/ja/keyboard.mdx
+++ b/docs/next/website/src/content/docs/ja/keyboard.mdx
@@ -15,7 +15,7 @@ Herdr はマウスネイティブです。キーバインドをひとつも覚ã�
プレフィックスはこれを解決します。プレフィックスキー (デフォルト `ctrl+b`) を押すと、次のキー入力はターミナルではなく Herdr に送られます。`prefix+c` は「`ctrl+b` を押して離し、次に `c` を押す」という意味です。何十ものキーを予約する代わりに、ひとつだけ予約します。
-いつでも `prefix+?` を押すと、すべての有効なバインドが表示されます。
+いつでも `prefix+?` を押すと、すべての有効なバインドが表示されます。キーバインドヘルプで `/` を押すと、アクションとショートカットを絞り込めます。Backspace でフィルターを編集し、`ctrl+u` でクリアできます。
## まずこの 5 つを覚える
diff --git a/docs/next/website/src/content/docs/keyboard.mdx b/docs/next/website/src/content/docs/keyboard.mdx
index 599bea3b..22283518 100644
--- a/docs/next/website/src/content/docs/keyboard.mdx
+++ b/docs/next/website/src/content/docs/keyboard.mdx
@@ -15,7 +15,7 @@ A terminal multiplexer sits between your terminal and the programs running insid
The prefix solves this. Press the prefix key, default `ctrl+b`, and the next keypress goes to Herdr instead of your terminal. `prefix+c` means: press `ctrl+b`, release, then press `c`. One reserved key instead of dozens.
-Press `prefix+?` at any time to see every active binding.
+Press `prefix+?` at any time to see every active binding. Press `/` in the keybind help to filter actions and shortcuts; use Backspace to edit the filter or `ctrl+u` to clear it.
## Learn these five first
diff --git a/docs/next/website/src/content/docs/zh-cn/keyboard.mdx b/docs/next/website/src/content/docs/zh-cn/keyboard.mdx
index 0dfea960..d7b0d297 100644
--- a/docs/next/website/src/content/docs/zh-cn/keyboard.mdx
+++ b/docs/next/website/src/content/docs/zh-cn/keyboard.mdx
@@ -15,7 +15,7 @@ Herdr 是鼠标原生的。你可以点击窗格、标签页、工作区和智èƒ
前缀键解决了这个问题。按下前缀键 (默认 `ctrl+b`),下一次按键会发给 Herdr 而不是终端。`prefix+c` 的意思是: 按下 `ctrl+b`,松开,再按 `c`。只保留一个键,而不是几十个。
-随时按 `prefix+?` 可以查看所有生效的绑定。
+随时按 `prefix+?` 可以查看所有生效的绑定。在快捷键帮助中按 `/` 即可筛选操作和快捷键;使用 Backspace 编辑筛选内容,或按 `ctrl+u` 清除。
## 先学这五个
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,
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<HelpGroup> {
groups
}
+fn filter_keybind_help_groups(groups: Vec<HelpGroup>, query: &str) -> Vec<HelpGroup> {
+ 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::<Vec<_>>();
+ (!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<HelpGroup> {
+ 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());
+ }
}