Files
ui-agregator/.sisyphus/plans/which-key-widget.md
T
2026-05-10 13:28:27 +02:00

792 lines
25 KiB
Markdown

# 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<KeyHint>` via `impl IntoIterator<Item = KeyHint>`, 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<KeyHint> = 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<String>, desc: impl Into<String>) -> 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<f64>, max: Option<f64> },
}
/// 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<GridLayout>;
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<usize> {
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<SortField> {
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 (<C-x>, <Esc>)
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<KeyHint>, // owned, sorted at construction
title: Option<String>,
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<Color>,
sort_fields: Vec<SortField>,
}
impl WhichKey {
/// Create widget. Takes ownership of hints, sorts immediately.
pub fn new(hints: impl IntoIterator<Item = KeyHint>) -> Self {
let mut hints: Vec<KeyHint> = 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<KeyHint> {
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 [("<C-d>", leaf), ("j", leaf), ("<Esc>", leaf)]
-> [("j", leaf), ("<C-d>", leaf), ("<Esc>", 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), ("<C-d>", leaf)]
-> [("b", group), ("1", leaf), ("a", leaf), ("G", leaf), ("<C-d>", 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<KeyHint> = (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`