From d49bcf0060cd7f696525628c5b2e313ee45f117f Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 8 Apr 2026 22:27:36 -0700 Subject: [PATCH] feat(ui): implement which-key popup for command completions Implement `WhichKey` popup to show available command completions. - Added `format_key_label` to convert `KeyCode` to human-readable strings. - Added `binding_hints` to `Keymap` to extract available commands for a given prefix. - Added `src/ui/which_key.rs` for the widget implementation. - Updated `src/ui/mod.rs` to export the new module. Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL) --- src/command/keymap.rs | 52 +++++++++++++++++++++++++++++++++ src/ui/mod.rs | 1 + src/ui/which_key.rs | 67 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 src/ui/which_key.rs diff --git a/src/command/keymap.rs b/src/command/keymap.rs index 192e71b..cc7bc31 100644 --- a/src/command/keymap.rs +++ b/src/command/keymap.rs @@ -8,6 +8,29 @@ use crate::ui::app::AppMode; use crate::ui::effect::Effect; use super::cmd::{self, CmdContext, CmdRegistry}; + +/// Format a KeyCode as a short human-readable label for which-key display. +fn format_key_label(code: &KeyCode) -> String { + match code { + KeyCode::Char(c) => c.to_string(), + KeyCode::Enter => "Enter".to_string(), + KeyCode::Esc => "Esc".to_string(), + KeyCode::Tab => "Tab".to_string(), + KeyCode::BackTab => "S-Tab".to_string(), + KeyCode::Backspace => "BS".to_string(), + KeyCode::Delete => "Del".to_string(), + KeyCode::Left => "←".to_string(), + KeyCode::Right => "→".to_string(), + KeyCode::Up => "↑".to_string(), + KeyCode::Down => "↓".to_string(), + KeyCode::Home => "Home".to_string(), + KeyCode::End => "End".to_string(), + KeyCode::PageUp => "PgUp".to_string(), + KeyCode::PageDown => "PgDn".to_string(), + KeyCode::F(n) => format!("F{n}"), + _ => format!("{code:?}"), + } +} // `cmd` module imported for `default_registry()` in default_keymaps() /// A key pattern that can be matched against a KeyEvent. @@ -146,6 +169,35 @@ impl Keymap { .insert(KeyPattern::Any, Binding::Cmd { name, args: vec![] }); } + /// Return human-readable hints for all concrete bindings in this keymap. + /// Used by the which-key popup to show available completions after a prefix key. + pub fn binding_hints(&self) -> Vec<(String, &'static str)> { + let mut hints: Vec<(String, &'static str)> = self + .bindings + .iter() + .filter_map(|(pattern, binding)| { + let label = match pattern { + KeyPattern::Key(code, _) => format_key_label(code), + KeyPattern::AnyChar | KeyPattern::Any => return None, + }; + let name = match binding { + Binding::Cmd { name, .. } => *name, + Binding::Prefix(_) => return None, + Binding::Sequence(steps) => { + if let Some((name, _)) = steps.first() { + *name + } else { + return None; + } + } + }; + Some((label, name)) + }) + .collect(); + hints.sort_by(|a, b| a.0.cmp(&b.0)); + hints + } + /// Look up the binding for a key. /// For Char keys, if exact (key, mods) match fails, retries with NONE /// modifiers since terminals vary in whether they send SHIFT for diff --git a/src/ui/mod.rs b/src/ui/mod.rs index de3919c..b678f14 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -9,3 +9,4 @@ pub mod import_wizard_ui; pub mod panel; pub mod tile_bar; pub mod view_panel; +pub mod which_key; diff --git a/src/ui/which_key.rs b/src/ui/which_key.rs new file mode 100644 index 0000000..100fe7c --- /dev/null +++ b/src/ui/which_key.rs @@ -0,0 +1,67 @@ +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Color, Modifier, Style}, + widgets::{Block, Borders, Clear, Widget}, +}; + +/// A compact popup showing available key completions after a prefix key, +/// Emacs which-key style. +pub struct WhichKeyWidget<'a> { + hints: &'a [(String, &'static str)], +} + +impl<'a> WhichKeyWidget<'a> { + pub fn new(hints: &'a [(String, &'static str)]) -> Self { + Self { hints } + } +} + +impl Widget for WhichKeyWidget<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + if self.hints.is_empty() { + return; + } + + // Size: width fits the longest "key command" line, height = hint count + border + let content_width = self + .hints + .iter() + .map(|(k, cmd)| k.len() + 2 + cmd.len()) + .max() + .unwrap_or(10); + let popup_w = (content_width as u16 + 4).min(area.width); // +4 for border + padding + let popup_h = (self.hints.len() as u16 + 2).min(area.height); // +2 for border + + // Position: bottom-center, above the status bar + let x = area.x + area.width.saturating_sub(popup_w) / 2; + let y = area.y + area.height.saturating_sub(popup_h + 2); // 2 lines above bottom + + let popup_area = Rect::new(x, y, popup_w, popup_h); + Clear.render(popup_area, buf); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)) + .title(" which-key "); + let inner = block.inner(popup_area); + block.render(popup_area, buf); + + let key_style = Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD); + let cmd_style = Style::default().fg(Color::Gray); + + for (i, (key_label, cmd_name)) in self.hints.iter().enumerate() { + if i >= inner.height as usize { + break; + } + let y = inner.y + i as u16; + buf.set_string(inner.x + 1, y, key_label, key_style); + let cmd_x = inner.x + 4; // fixed column for command names + if cmd_x < inner.x + inner.width { + buf.set_string(cmd_x, y, cmd_name, cmd_style); + } + } + } +}