docs: add which-key widget implementation plan
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -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<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`
|
||||||
Reference in New Issue
Block a user