# which-key Widget: Ratatui Rendering Crate ## Goal Workspace member crate (`crates/which-key/`) providing a passive, decoupled which-key popup widget for ratatui. Accepts generic key-description data from ANY source — not coupled to `evil-keys` or any specific keybinding library. Implements ratatui's `Widget` trait. Ports the multi-column grid layout algorithm from which-key.nvim. --- ## Design Principles 1. **Passive** — does not define, dispatch, or intercept keybindings (like Emacs/Neovim which-key) 2. **Decoupled** — accepts `Vec` via `impl IntoIterator`, no dependency on `evil-keys` 3. **ratatui-native** — implements `Widget` trait, composes with `Block`, `Style`, `Clear` 4. **Minimal** — only rendering + layout logic, no event handling, no timers, no state machine 5. **Own data** — `WhichKey` owns its hints (sorts once at construction, no per-frame allocation) 6. **Separate layout from render** — `layout()` computes Rect, `render()` draws into it --- ## Public API Surface ```rust use which_key::{WhichKey, KeyHint, Position}; // 1. Build data from ANY source let hints = vec![ KeyHint::new("b", "+buffer").group(), KeyHint::new("h", "help"), KeyHint::new("q", "quit"), KeyHint::new("n", "notifications"), ]; // 2. Create widget (owns + sorts hints at construction time) let popup = WhichKey::new(hints) // takes ownership, sorts immediately .title("SPC") .separator("→") .group_prefix("+") .position(Position::BottomLeft) .column_spacing(3) .min_column_width(20) .max_rows(15) .key_style(Style::default().fg(Color::Yellow).bold()) .separator_style(Style::default().fg(Color::DarkGray)) .desc_style(Style::default().fg(Color::White)) .group_style(Style::default().fg(Color::Cyan)) .border_style(Style::default().fg(Color::Blue)) .bg(Color::Black); // 3. Compute layout (separate from render — caller knows the Rect) let popup_rect = popup.layout(frame.area()); // 4. Render into computed Rect frame.render_widget(&popup, popup_rect); ``` ### Integration with evil-keys (consumer-side, NOT in this crate) ```rust // In ui-agregator's renderer — bridge evil-keys data to which-key widget if let Some(entries) = dispatcher.which_key_entries() { let hints: Vec = entries .iter() .map(|e| { let mut h = KeyHint::new(&e.key, &e.description); if e.is_group { h = h.group(); } h }) .collect(); let popup = WhichKey::new(hints) .title(&dispatcher.pending_display()); let popup_rect = popup.layout(frame.area()); frame.render_widget(&popup, popup_rect); } ``` --- ## Crate Structure ``` crates/which-key/ Cargo.toml src/ lib.rs Public re-exports hint.rs KeyHint data type (input to widget) layout.rs Dimension calculation + multi-column grid algorithm render.rs Widget impl — formats entries, renders to Buffer sort.rs Multi-level sort (group, alphanum, case, natural) ``` ### Dependencies ```toml [package] name = "which-key" version = "0.1.0" edition = "2021" [dependencies] ratatui = "0.29" unicode-width = "0.2" [dev-dependencies] proptest = "1.4" insta = "1.40.0" ``` No `crossterm`. No `evil-keys`. No `tokio`. Pure rendering. --- ## Core Types ### hint.rs ```rust #[derive(Clone, Debug, PartialEq, Eq)] pub struct KeyHint { pub key: String, pub description: String, pub is_group: bool, } impl KeyHint { pub fn new(key: impl Into, desc: impl Into) -> Self { Self { key: key.into(), description: desc.into(), is_group: false, } } pub fn group(mut self) -> Self { self.is_group = true; self } } ``` ### layout.rs ```rust use std::borrow::Cow; use unicode_width::UnicodeWidthStr; /// Dimension calculation — ported from which-key.nvim layout.lua. /// Internal only (pub(crate)). /// /// Rules: /// |size| < 1.0 → percentage of parent (0.5 = 50%) /// size < 0 → parent + size (subtract from parent) /// Constraints applied after: min, max (can be percentages too) /// Final: floor(clamp(0, parent, result) + 0.5) pub(crate) fn dim(size: f64, parent: usize, constraints: &[DimConstraint]) -> usize; /// Internal constraint type (not exposed publicly — avoids conflict with ratatui::layout::Constraint) pub(crate) enum DimConstraint { Fixed(f64), Range { min: Option, max: Option }, } /// Multi-column grid layout calculation. /// /// Parameters: /// - item_count: number of entries to display /// - max_entry_width: widest entry in display columns (computed by caller) /// - container_width: available terminal width /// - min_column_width: minimum width per column (default 20) /// - spacing: spaces between columns (default 3) /// /// Returns None if item_count == 0 or container_width == 0. pub fn grid_layout( item_count: usize, max_entry_width: usize, container_width: usize, min_column_width: usize, spacing: usize, ) -> Option; pub struct GridLayout { pub columns: usize, pub rows: usize, pub column_width: usize, } impl GridLayout { /// Convert (column, row) to item index (column-major order). /// Returns None if index exceeds item_count. pub fn item_index(&self, col: usize, row: usize, item_count: usize) -> Option { let idx = col * self.rows + row; if idx < item_count { Some(idx) } else { None } } } /// Truncate string to fit within max_width display columns. /// Uses unicode-width for correct CJK/emoji handling. /// Appends ellipsis "…" if truncated. /// Returns Cow::Borrowed when no truncation needed. pub fn truncate(s: &str, max_width: usize) -> Cow<'_, str>; /// Pad string to exact display width with given alignment. /// Uses ratatui's Alignment enum. /// If content is wider than width, returns content unchanged. pub fn pad(s: &str, width: usize, align: ratatui::layout::Alignment) -> String; ``` ### sort.rs ```rust use crate::hint::KeyHint; /// Sort hints in-place with multi-level comparator. /// Order: groups first → alphanumeric before special → natural number sort → lowercase before uppercase. /// Configurable via sort_fields parameter. pub fn sort_hints(hints: &mut [KeyHint], fields: &[SortField]); /// Default sort order. pub fn default_sort_order() -> Vec { vec![SortField::Group, SortField::Alphanum, SortField::Natural, SortField::Case] } #[derive(Clone, Copy, Debug)] pub enum SortField { Group, // groups after non-groups Alphanum, // alphanumeric keys before special keys (, ) Natural, // natural number sort (F2 before F10) Case, // lowercase before uppercase } ``` ### render.rs ```rust use ratatui::{ buffer::Buffer, layout::{Alignment, Rect}, style::{Color, Style}, text::{Line, Span}, widgets::{Block, Borders, Clear, Padding, Widget}, }; use crate::hint::KeyHint; use crate::layout::{grid_layout, truncate, pad}; use crate::sort::{sort_hints, default_sort_order, SortField}; #[derive(Clone, Copy, Debug, Default)] pub enum Position { #[default] BottomLeft, BottomRight, BottomCenter, TopLeft, TopRight, TopCenter, Center, } pub struct WhichKey { hints: Vec, // owned, sorted at construction title: Option, position: Position, separator: String, group_prefix: String, column_spacing: usize, min_column_width: usize, max_rows: usize, // overflow protection (default: 20) padding: Padding, // ratatui's Padding type key_style: Style, separator_style: Style, desc_style: Style, group_style: Style, border_style: Style, bg: Option, sort_fields: Vec, } impl WhichKey { /// Create widget. Takes ownership of hints, sorts immediately. pub fn new(hints: impl IntoIterator) -> Self { let mut hints: Vec = hints.into_iter().collect(); let fields = default_sort_order(); sort_hints(&mut hints, &fields); Self { hints, title: None, position: Position::default(), separator: "→".to_string(), group_prefix: "+".to_string(), column_spacing: 3, min_column_width: 20, max_rows: 20, padding: Padding::new(2, 2, 1, 1), // left, right, top, bottom key_style: Style::default(), separator_style: Style::default(), desc_style: Style::default(), group_style: Style::default(), border_style: Style::default(), bg: None, sort_fields: fields, } } // Builder methods (all return Self): // title, position, separator, group_prefix, // column_spacing, min_column_width, max_rows, padding, // key_style, separator_style, desc_style, group_style, // border_style, bg, sort_fields /// Compute the popup Rect given available screen area. /// Separate from render — caller can use this Rect for hit testing or composition. pub fn layout(&self, available: Rect) -> Rect { if self.hints.is_empty() { return Rect::default(); } // 1. Compute max_entry_width from sorted hints // 2. Compute grid layout (columns, rows, column_width) // 3. Clamp rows to max_rows // 4. Calculate popup dimensions (content + padding + border) // 5. Position within available area based on self.position // 6. Return computed Rect } } impl Widget for &WhichKey { fn render(self, area: Rect, buf: &mut Buffer) { if self.hints.is_empty() || area.width == 0 || area.height == 0 { return; } // 1. Clear area (overlay) // 2. Render Block (border + title) // 3. Compute grid layout for inner area // 4. Format and render entries in column-major grid order with styles } } ``` --- ## Algorithms ### Dimension Calculation (ported from which-key.nvim `layout.dim`, pub(crate)) ``` dim(size, parent, constraints): 1. if |size| < 1.0: size = parent_f64 * size // percentage 2. if size < 0: size = parent_f64 + size // subtract 3. for each constraint: if Fixed(v): size = dim(v, parent, []) // recursive if Range{min, max}: min_val = min.map(|m| dim(m, parent, [])).unwrap_or(0) max_val = max.map(|m| dim(m, parent, [])).unwrap_or(parent) size = clamp(size, min_val, max_val) 4. return floor(clamp(size, 0, parent_f64) + 0.5) as usize // round ``` ### Multi-Column Grid Layout (ported from which-key.nvim `view.lua`) ``` grid_layout(item_count, max_entry_width, container_width, min_column_width, spacing): if item_count == 0 || container_width == 0: return None 1. box_width = dim(max_entry_width, container_width, Range{min: min_column_width}) 2. columns = max(floor(container_width / (box_width + spacing)), 1) 3. column_width = floor(container_width / columns) 4. rows = max(ceil(item_count / columns), 1) 5. return Some(GridLayout { columns, rows, column_width }) Item index (column-major): index(col, row) = col * rows + row Returns None if index >= item_count ``` ### max_entry_width Calculation (in render.rs, before calling grid_layout) ``` max_entry_width(hints, separator, group_prefix): max over all hints of: display_width(hint.key) + display_width(" {separator} ") + display_width(desc_with_prefix) where desc_with_prefix = if hint.is_group: "{group_prefix}{desc}" else: desc ``` ### Entry Formatting ``` format_entry(hint, key_width, column_width): key_part = pad(hint.key, key_width, Align::Right) // right-aligned sep_part = " {separator} " desc_text = if hint.is_group: "{group_prefix}{hint.description}" else: hint.description desc_part = truncate(desc_text, column_width - key_width - sep_width) desc_part = pad(desc_part, remaining_width, Align::Left) return [ Span(key_part, key_style), Span(sep_part, separator_style), Span(desc_part, if hint.is_group: group_style else: desc_style), ] ``` ### Popup Positioning ``` position_popup(area, popup_width, popup_height, position): match position: BottomLeft: (area.x, area.bottom() - popup_height) BottomRight: (area.right() - popup_width, area.bottom() - popup_height) BottomCenter: (area.x + (area.width - popup_width) / 2, area.bottom() - popup_height) TopLeft: (area.x, area.y) TopRight: (area.right() - popup_width, area.y) TopCenter: (area.x + (area.width - popup_width) / 2, area.y) Center: centered(area, popup_width, popup_height) ``` --- ## Integration into ui-agregator ### Step 1: Add dependency ```toml # Root Cargo.toml [workspace] members = ["crates/evil-keys", "crates/which-key"] # ui-agregator dependencies [dependencies] which-key = { path = "crates/which-key" } ``` ### Step 2: Bridge evil-keys → which-key (in ui-agregator, not in either crate) ```rust // src/presentation/which_key_popup.rs use evil_keys::WhichKeyEntry; use which_key::{KeyHint, WhichKey}; pub fn to_hints(entries: &[WhichKeyEntry]) -> Vec { entries .iter() .map(|e| { let mut h = KeyHint::new(&e.key, &e.description); if e.is_group { h = h.group(); } h }) .collect() } ``` ### Step 3: Render in app_renderer.rs ```rust // After modal rendering, before notifications if let Some(entries) = dispatcher.which_key_entries() { let hints = to_hints(&entries); let popup = WhichKey::new(hints) .title(&dispatcher.pending_display()) .position(Position::BottomLeft) .key_style(Style::default().fg(theme::YELLOW).add_modifier(Modifier::BOLD)) .separator_style(Style::default().fg(theme::GRAY)) .desc_style(Style::default().fg(theme::FG2)) .group_style(Style::default().fg(theme::AQUA)) .border_style(Style::default().fg(theme::BG3)) .bg(theme::BG0); let popup_rect = popup.layout(frame.area()); frame.render_widget(&popup, popup_rect); } ``` ### Step 4: Trigger in main.rs ```rust // Already done: dispatcher.pending_elapsed() and check_timeout() // The renderer just checks dispatcher.which_key_entries() // No new state needed — the popup renders when entries exist ``` --- ## Implementation Phases ### Phase 1: Core crate + basic rendering Files: `hint.rs`, `layout.rs`, `render.rs`, `lib.rs` Skip: sort.rs (use simple groups-first + alphabetical) Result: Which-key popup renders with correct multi-column layout Test: layout dim() + grid_layout() + truncate() + pad() Effort: 3-4h ### Phase 2: Sorting + polish Files: `sort.rs`, update `render.rs` Result: Entries sorted naturally, groups before leaves Test: sort field combinations, natural number sort Effort: 1-2h ### Phase 3: Wire into ui-agregator Files: `src/presentation/which_key_popup.rs`, update `app_renderer.rs`, update `main.rs` Result: Popup appears after pressing SPC or g and waiting 1s Test: manual visual verification + snapshot tests Effort: 1-2h --- ## Testing Strategy ### Dependencies ```toml [dev-dependencies] proptest = "1.4" insta = "1.40.0" ``` ### Test Structure ``` crates/which-key/ src/ hint.rs # #[cfg(test)] mod tests layout.rs # #[cfg(test)] mod tests — dim() + grid + truncate + pad sort.rs # #[cfg(test)] mod tests — sort field combinations render.rs # #[cfg(test)] mod tests — snapshot tests with TestBackend tests/ layout_dim.rs # Ported from which-key.nvim layout_spec.lua (31 tests) grid_layout.rs # Multi-column grid scenarios render_snapshots.rs # Visual output verification via insta proptest_layout.rs # Property-based invariants ``` ### 1. Dimension Calculation — Ported from which-key.nvim (31 tests) ```rust // Direct port of layout_spec.lua dim(100.0, 200, &[]) == 100 dim(0.2, 100, &[]) == 20 dim(-0.2, 100, &[]) == 80 dim(-20.0, 100, &[]) == 80 dim(1.0, 100, &[]) == 1 dim(100.0, 200, &[Range{min: Some(50.0), ..}]) == 100 dim(100.0, 200, &[Range{max: Some(150.0),..}]) == 100 dim(100.0, 200, &[Range{min:50, max:150}]) == 100 dim(100.0, 200, &[Range{min:150, max:150}]) == 150 dim(0.2, 100, &[Range{min:20, max:150}]) == 20 dim(0.2, 100, &[Range{min:20, max:50}]) == 20 dim(f64::MAX, 200, &[]) == 200 // infinity clamped dim(-0.5, 200, &[]) == 100 dim(0.5, 200, &[]) == 100 dim(0.5, 200, &[Range{min:150, ..}]) == 150 dim(-0.5, 200, &[Range{max:50, ..}]) == 50 dim(300.0, 200, &[Range{max:250, ..}]) == 200 // clamped to parent dim(300.0, 200, &[Range{min:250, ..}]) == 200 dim(-100.0, 100, &[Range{min:20, max:90}]) == 20 dim(-200.0, 100, &[Range{min:-50, max:-50}]) == 0 dim(0.2, 100, &[Range{min:0.5, ..}]) == 50 // percentage min dim(-200.0, 100, &[]) == 0 dim(-1.0, 100, &[]) == 99 dim(-0.1, 100, &[]) == 90 dim(0.1, 100, &[]) == 10 dim(14.0, 212, &[Fixed(0.9)]) == 191 // constraint as percentage ``` ### 2. Grid Layout Tests ```rust // === Basic grid calculation === // grid_layout(item_count, max_entry_width, container_width, min_column_width, spacing) grid_layout(6, 20, 80, 20, 3) -> Some(GridLayout { columns: 3, rows: 2, column_width: 26 }) grid_layout(1, 20, 80, 20, 3) -> Some(GridLayout { columns: 1, rows: 1, column_width: 80 }) grid_layout(10, 30, 40, 20, 3) -> Some(GridLayout { columns: 1, rows: 10, column_width: 40 }) grid_layout(0, 20, 80, 20, 3) -> None // empty grid_layout(5, 20, 0, 20, 3) -> None // zero container width // === Column-major indexing === // 6 items, 2 columns, 3 rows: // Item 0 Item 3 // Item 1 Item 4 // Item 2 Item 5 grid.item_index(0, 0, 6) == Some(0) grid.item_index(0, 1, 6) == Some(1) grid.item_index(0, 2, 6) == Some(2) grid.item_index(1, 0, 6) == Some(3) grid.item_index(1, 1, 6) == Some(4) grid.item_index(1, 2, 6) == Some(5) grid.item_index(2, 0, 6) == None // out of bounds // === Uneven last column === // 5 items, 2 columns, 3 rows: // Item 0 Item 3 // Item 1 Item 4 // Item 2 (empty) grid.item_index(1, 2, 5) == None // === Narrow terminal === grid_layout(10, 20, 15, 20, 3) -> Some(columns: 1) // can't fit 2 columns // === Wide terminal === grid_layout(3, 20, 200, 20, 3) -> Some(columns: 3, rows: 1) ``` ### 3. Truncation Tests ```rust truncate("hello world", 11) == "hello world" // fits exactly truncate("hello world", 10) == "hello wo…" // truncated truncate("hello world", 5) == "hel…" // short truncate("hello world", 1) == "…" // minimal truncate("hello world", 0) == "" // zero width truncate("", 10) == "" // empty input truncate("🔥🔥🔥", 5) == "🔥🔥…" // emoji (each 2 wide) truncate("あいう", 5) == "あい…" // CJK (each 2 wide) truncate("aあb", 4) == "aあ…" // mixed widths truncate("hello", 100) == "hello" // width > content ``` ### 4. Padding Tests ```rust pad("hi", 6, Align::Left) == "hi " pad("hi", 6, Align::Right) == " hi" pad("hi", 6, Align::Center) == " hi " pad("hi", 7, Align::Center) == " hi " // odd: extra space on right pad("hi", 2, Align::Left) == "hi" // exact fit pad("hi", 1, Align::Left) == "hi" // width < content: no truncation pad("", 5, Align::Left) == " " // empty string pad("🔥", 4, Align::Left) == "🔥 " // emoji width = 2 ``` ### 5. Sort Tests ```rust // === Groups before leaves === sort [("z", leaf), ("a", group), ("m", leaf)] -> [("a", group), ("m", leaf), ("z", leaf)] // === Alphanum before special === sort [("", leaf), ("j", leaf), ("", leaf)] -> [("j", leaf), ("", leaf), ("", leaf)] // === Natural number sort === sort [("F10", leaf), ("F2", leaf), ("F1", leaf)] -> [("F1", leaf), ("F2", leaf), ("F10", leaf)] // === Case: lowercase before uppercase === sort [("G", leaf), ("g", leaf)] -> [("g", leaf), ("G", leaf)] // === Combined default sort === sort [("G", leaf), ("b", group), ("a", leaf), ("1", leaf), ("", leaf)] -> [("b", group), ("1", leaf), ("a", leaf), ("G", leaf), ("", leaf)] ``` ### 6. Render Snapshot Tests (via insta + ratatui TestBackend) ```rust #[test] fn snapshot_basic_popup() { let hints = vec![ KeyHint::new("b", "+buffer").group(), KeyHint::new("h", "help"), KeyHint::new("q", "quit"), ]; let widget = WhichKey::new(hints).title("SPC"); let area = Rect::new(0, 0, 40, 10); let popup_rect = widget.layout(area); let mut buf = Buffer::empty(area); (&widget).render(popup_rect, &mut buf); insta::assert_snapshot!(buf_to_string(&buf)); } #[test] fn snapshot_multi_column() { /* 10+ items forcing 2-3 columns */ } #[test] fn snapshot_wide_terminal() { /* 200 width, few items */ } #[test] fn snapshot_narrow_terminal() { /* 20 width, forces single column */ } #[test] fn snapshot_empty_hints() { /* empty vec → renders nothing (Rect::default()) */ } #[test] fn snapshot_single_hint() { /* one item */ } #[test] fn snapshot_long_descriptions() { /* descriptions that get truncated */ } #[test] fn snapshot_unicode_keys() { /* emoji/CJK keys */ } ``` ### 7. Property-Based Tests ```rust proptest! { // dim() always returns 0..=parent fn dim_always_within_bounds(size in -1000.0..1000.0f64, parent in 1usize..500) { let result = dim(size, parent, &[]); assert!(result <= parent); } // grid_layout columns * rows >= item_count (when Some) fn grid_covers_all_items(count in 1usize..100, entry_w in 5usize..50, width in 20usize..200) { if let Some(grid) = grid_layout(count, entry_w, width, 20, 3) { assert!(grid.columns * grid.rows >= count); } } // truncate never exceeds max_width fn truncate_respects_width(s in "[a-zA-Z0-9 ]{0,50}", width in 0usize..60) { let result = truncate(&s, width); assert!(UnicodeWidthStr::width(result.as_ref()) <= width); } // pad produces exact target width (when target >= content width) fn pad_exact_width(s in "[a-z]{0,10}", extra in 0usize..20) { let content_width = UnicodeWidthStr::width(s.as_str()); let width = content_width + extra; let result = pad(&s, width, Alignment::Left); assert_eq!(UnicodeWidthStr::width(result.as_str()), width); } // sort is stable (equal elements preserve order) fn sort_is_stable(hints in vec(key_hint_strategy(), 0..20)) { let mut sorted = hints.clone(); sort_hints(&mut sorted, &default_sort_order()); // verify: if two hints compare equal on all sort fields, their relative order is preserved } // WhichKey::layout never panics and always returns rect within available fn layout_within_bounds(count in 0usize..50, width in 0u16..200, height in 0u16..50) { let hints: Vec = (0..count) .map(|i| KeyHint::new(format!("{}", (b'a' + (i as u8 % 26)) as char), "desc")) .collect(); let widget = WhichKey::new(hints); let available = Rect::new(0, 0, width, height); let result = widget.layout(available); assert!(result.x + result.width <= available.width); assert!(result.y + result.height <= available.height); } } ``` ### 8. Corner Cases ```rust // === Empty input === WhichKey::new(vec![]).layout(area) == Rect::default() // zero-size rect // render on Rect::default() is no-op // === All groups, no leaves === // Should render all with group_prefix // === All leaves, no groups === // Should render all without prefix // === Very long key (wider than column) === KeyHint::new("SPC b l w q h n p", "deeply nested") // key itself exceeds column width → truncated // === Description with special characters === KeyHint::new("a", "description with → and … and 🔥") // === Zero-width terminal === WhichKey::new(hints).layout(Rect::new(0, 0, 0, 0)) == Rect::default() // render is no-op // === 1x1 terminal === WhichKey::new(hints).layout(Rect::new(0, 0, 1, 1)) // returns minimal rect, no crash // render draws what fits (likely nothing visible) // === Duplicate keys === vec![KeyHint::new("a", "first"), KeyHint::new("a", "second")] // both rendered // === 100+ items with max_rows === // With .max_rows(15): shows first 15, truncates rest // Without: shows all in multi-column (may exceed screen → clamped to available height) // === Overflow clamping === // If computed popup_height > available.height: clamp to available.height // If computed popup_width > available.width: clamp to available.width ``` --- ## What NOT to Build - Event handling (the consumer handles keys) - Timer/timeout (the consumer decides when to show/hide) - Keybinding introspection (consumer provides data) - Scrolling (popup should fit; add in v2 if needed) - Breadcrumb trail (add in v2; consumer can put it in title for now) - Animation (unnecessary for TUI) - Configuration files (styles passed via builder) - Dependency on `evil-keys` or `crossterm`