diff --git a/.sisyphus/plans/which-key-widget.md b/.sisyphus/plans/which-key-widget.md new file mode 100644 index 0000000..52de844 --- /dev/null +++ b/.sisyphus/plans/which-key-widget.md @@ -0,0 +1,791 @@ +# 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`