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

25 KiB

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 dataWhichKey owns its hints (sorts once at construction, no per-frame allocation)
  6. Separate layout from renderlayout() computes Rect, render() draws into it

Public API Surface

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)

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

[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

#[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

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

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

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

# 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)

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

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

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

[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)

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

// === 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

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

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

// === 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)

#[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

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

// === 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