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)
This commit is contained in:
Edward Langley
2026-04-08 22:27:36 -07:00
parent bbc009b088
commit d49bcf0060
3 changed files with 120 additions and 0 deletions

View File

@ -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

View File

@ -9,3 +9,4 @@ pub mod import_wizard_ui;
pub mod panel;
pub mod tile_bar;
pub mod view_panel;
pub mod which_key;

67
src/ui/which_key.rs Normal file
View File

@ -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);
}
}
}
}