feat(which-key): add passive rendering widget crate with multi-column grid layout

Decoupled from evil-keys — accepts generic KeyHint data from any source.
Ports which-key.nvim's dimension calculation and column-major grid algorithm.
Features: unicode-aware truncation, multi-level sorting (group/alphanum/natural/case),
configurable styling via builder pattern, auto-sizing with Position-based placement.

76 tests covering dim() (31 ported from nvim), grid layout, truncation, padding, and sorting.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Alexander
2026-05-10 13:28:27 +02:00
parent eb114fc614
commit 498e92f2e4
6 changed files with 1244 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
[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"
+96
View File
@@ -0,0 +1,96 @@
//! Key hint representation for the which-key widget.
/// A single key hint entry showing a key and its description.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct KeyHint {
/// The key or key combination (e.g., "a", "gg", "<C-d>")
pub key: String,
/// Description of what the key does
pub description: String,
/// Whether this hint represents a group (prefix for more keys)
pub is_group: bool,
}
impl KeyHint {
/// Create a new key hint.
pub fn new(key: impl Into<String>, desc: impl Into<String>) -> Self {
Self {
key: key.into(),
description: desc.into(),
is_group: false,
}
}
/// Mark this hint as a group (prefix key).
pub fn group(mut self) -> Self {
self.is_group = true;
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_construction() {
let hint = KeyHint::new("a", "action");
assert_eq!(hint.key, "a");
assert_eq!(hint.description, "action");
assert!(!hint.is_group);
}
#[test]
fn test_string_types() {
let hint = KeyHint::new(String::from("b"), String::from("buffer"));
assert_eq!(hint.key, "b");
assert_eq!(hint.description, "buffer");
}
#[test]
fn test_group_builder() {
let hint = KeyHint::new("g", "+goto").group();
assert!(hint.is_group);
assert_eq!(hint.key, "g");
assert_eq!(hint.description, "+goto");
}
#[test]
fn test_group_preserves_other_fields() {
let hint = KeyHint::new("SPC", "leader").group();
assert_eq!(hint.key, "SPC");
assert_eq!(hint.description, "leader");
assert!(hint.is_group);
}
#[test]
fn test_clone() {
let hint1 = KeyHint::new("x", "delete");
let hint2 = hint1.clone();
assert_eq!(hint1, hint2);
}
#[test]
fn test_debug() {
let hint = KeyHint::new("q", "quit");
let debug_str = format!("{:?}", hint);
assert!(debug_str.contains("KeyHint"));
assert!(debug_str.contains("quit"));
}
#[test]
fn test_equality() {
let hint1 = KeyHint::new("a", "action");
let hint2 = KeyHint::new("a", "action");
let hint3 = KeyHint::new("b", "action");
assert_eq!(hint1, hint2);
assert_ne!(hint1, hint3);
}
#[test]
fn test_group_affects_equality() {
let hint1 = KeyHint::new("a", "action");
let hint2 = KeyHint::new("a", "action").group();
assert_ne!(hint1, hint2);
}
}
+493
View File
@@ -0,0 +1,493 @@
use std::borrow::Cow;
use ratatui::layout::Alignment;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
#[derive(Clone, Copy, Debug)]
pub(crate) enum DimConstraint {
#[allow(dead_code)]
Fixed(f64),
Range { min: Option<f64>, max: Option<f64> },
}
pub(crate) fn dim(size: f64, parent: usize, constraints: &[DimConstraint]) -> usize {
let parent_f = parent as f64;
let mut s = if size.abs() < 1.0 {
parent_f * size
} else {
size
};
if s < 0.0 {
s += parent_f;
}
for c in constraints {
match c {
DimConstraint::Fixed(v) => {
s = dim(*v, parent, &[]) as f64;
}
DimConstraint::Range { min, max } => {
let min_val = min.map(|m| dim(m, parent, &[]) as f64).unwrap_or(0.0);
let max_val = max.map(|m| dim(m, parent, &[]) as f64).unwrap_or(parent_f);
s = s.clamp(min_val, max_val);
}
}
}
(s.clamp(0.0, parent_f) + 0.5).floor() as usize
}
pub struct GridLayout {
pub columns: usize,
pub rows: usize,
pub column_width: usize,
}
impl GridLayout {
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
}
}
}
pub fn grid_layout(
item_count: usize,
max_entry_width: usize,
container_width: usize,
min_column_width: usize,
spacing: usize,
) -> Option<GridLayout> {
if item_count == 0 || container_width == 0 {
return None;
}
let box_width = dim(
max_entry_width as f64,
container_width,
&[DimConstraint::Range {
min: Some(min_column_width as f64),
max: None,
}],
);
let columns = ((container_width) / (box_width + spacing)).max(1);
let column_width = container_width / columns;
let rows = item_count.div_ceil(columns);
Some(GridLayout {
columns,
rows,
column_width,
})
}
pub fn truncate(s: &str, max_width: usize) -> Cow<'_, str> {
if max_width == 0 {
return Cow::Borrowed("");
}
let width = UnicodeWidthStr::width(s);
if width <= max_width {
return Cow::Borrowed(s);
}
let target = max_width.saturating_sub(1);
let mut current_width = 0;
let mut byte_end = 0;
for ch in s.chars() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if current_width + ch_width > target {
break;
}
current_width += ch_width;
byte_end += ch.len_utf8();
}
let mut result = String::with_capacity(byte_end + 3);
result.push_str(&s[..byte_end]);
result.push('…');
Cow::Owned(result)
}
pub fn pad(s: &str, width: usize, align: Alignment) -> String {
let content_width = UnicodeWidthStr::width(s);
if content_width >= width {
return s.to_string();
}
let padding = width - content_width;
match align {
Alignment::Left => format!("{}{}", s, " ".repeat(padding)),
Alignment::Right => format!("{}{}", " ".repeat(padding), s),
Alignment::Center => {
let left = padding / 2;
let right = padding - left;
format!("{}{}{}", " ".repeat(left), s, " ".repeat(right))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
// === dim() tests ported from which-key.nvim layout_spec.lua ===
#[test]
fn dim_fixed_within_bounds() {
assert_eq!(dim(100.0, 200, &[]), 100);
}
#[test]
fn dim_percentage_20() {
assert_eq!(dim(0.2, 100, &[]), 20);
}
#[test]
fn dim_negative_percentage() {
assert_eq!(dim(-0.2, 100, &[]), 80);
}
#[test]
fn dim_negative_absolute() {
assert_eq!(dim(-20.0, 100, &[]), 80);
}
#[test]
fn dim_one() {
assert_eq!(dim(1.0, 100, &[]), 1);
}
#[test]
fn dim_with_min() {
assert_eq!(
dim(
100.0,
200,
&[DimConstraint::Range {
min: Some(50.0),
max: None
}]
),
100
);
}
#[test]
fn dim_with_max() {
assert_eq!(
dim(
100.0,
200,
&[DimConstraint::Range {
min: None,
max: Some(150.0)
}]
),
100
);
}
#[test]
fn dim_within_range() {
assert_eq!(
dim(
100.0,
200,
&[DimConstraint::Range {
min: Some(50.0),
max: Some(150.0)
}]
),
100
);
}
#[test]
fn dim_forced_to_min_equals_max() {
assert_eq!(
dim(
100.0,
200,
&[DimConstraint::Range {
min: Some(150.0),
max: Some(150.0)
}]
),
150
);
}
#[test]
fn dim_percentage_at_min() {
assert_eq!(
dim(
0.2,
100,
&[DimConstraint::Range {
min: Some(20.0),
max: Some(150.0)
}]
),
20
);
}
#[test]
fn dim_percentage_within_range() {
assert_eq!(
dim(
0.2,
100,
&[DimConstraint::Range {
min: Some(20.0),
max: Some(50.0)
}]
),
20
);
}
#[test]
fn dim_infinity_clamped() {
assert_eq!(dim(f64::MAX, 200, &[]), 200);
}
#[test]
fn dim_half_subtracted() {
assert_eq!(dim(-0.5, 200, &[]), 100);
}
#[test]
fn dim_half_of_parent() {
assert_eq!(dim(0.5, 200, &[]), 100);
}
#[test]
fn dim_percentage_below_min() {
assert_eq!(
dim(
0.5,
200,
&[DimConstraint::Range {
min: Some(150.0),
max: None
}]
),
150
);
}
#[test]
fn dim_subtraction_above_max() {
assert_eq!(
dim(
-0.5,
200,
&[DimConstraint::Range {
min: None,
max: Some(50.0)
}]
),
50
);
}
#[test]
fn dim_overflow_with_max() {
assert_eq!(
dim(
300.0,
200,
&[DimConstraint::Range {
min: None,
max: Some(250.0)
}]
),
200
);
}
#[test]
fn dim_overflow_with_min() {
assert_eq!(
dim(
300.0,
200,
&[DimConstraint::Range {
min: Some(250.0),
max: None
}]
),
200
);
}
#[test]
fn dim_underflow_to_min() {
assert_eq!(
dim(
-100.0,
100,
&[DimConstraint::Range {
min: Some(20.0),
max: Some(90.0)
}]
),
20
);
}
#[test]
fn dim_negative_constraints() {
assert_eq!(
dim(
-200.0,
100,
&[DimConstraint::Range {
min: Some(-50.0),
max: Some(-50.0)
}]
),
50
);
}
#[test]
fn dim_percentage_min() {
assert_eq!(
dim(
0.2,
100,
&[DimConstraint::Range {
min: Some(0.5),
max: None
}]
),
50
);
}
#[test]
fn dim_large_underflow() {
assert_eq!(dim(-200.0, 100, &[]), 0);
}
#[test]
fn dim_subtract_one() {
assert_eq!(dim(-1.0, 100, &[]), 99);
}
#[test]
fn dim_subtract_10_percent() {
assert_eq!(dim(-0.1, 100, &[]), 90);
}
#[test]
fn dim_10_percent() {
assert_eq!(dim(0.1, 100, &[]), 10);
}
#[test]
fn dim_fixed_constraint() {
assert_eq!(dim(14.0, 212, &[DimConstraint::Fixed(0.9)]), 191);
}
// === grid_layout tests ===
#[test]
fn grid_basic() {
let g = grid_layout(6, 20, 80, 20, 3).unwrap();
assert_eq!(g.columns, 3);
assert_eq!(g.rows, 2);
}
#[test]
fn grid_single_item() {
let g = grid_layout(1, 20, 80, 20, 3).unwrap();
assert_eq!(g.columns, 3);
assert_eq!(g.rows, 1);
}
#[test]
fn grid_narrow() {
let g = grid_layout(10, 30, 40, 20, 3).unwrap();
assert_eq!(g.columns, 1);
}
#[test]
fn grid_empty() {
assert!(grid_layout(0, 20, 80, 20, 3).is_none());
}
#[test]
fn grid_zero_width() {
assert!(grid_layout(5, 20, 0, 20, 3).is_none());
}
#[test]
fn grid_column_major_index() {
let g = GridLayout {
columns: 2,
rows: 3,
column_width: 30,
};
assert_eq!(g.item_index(0, 0, 6), Some(0));
assert_eq!(g.item_index(0, 1, 6), Some(1));
assert_eq!(g.item_index(0, 2, 6), Some(2));
assert_eq!(g.item_index(1, 0, 6), Some(3));
assert_eq!(g.item_index(1, 1, 6), Some(4));
assert_eq!(g.item_index(1, 2, 6), Some(5));
assert_eq!(g.item_index(2, 0, 6), None);
}
#[test]
fn grid_uneven_last_column() {
let g = GridLayout {
columns: 2,
rows: 3,
column_width: 30,
};
assert_eq!(g.item_index(1, 2, 5), None);
}
// === truncate tests ===
#[test]
fn truncate_fits() {
assert_eq!(truncate("hello world", 11).as_ref(), "hello world");
}
#[test]
fn truncate_one_short() {
assert_eq!(truncate("hello world", 10).as_ref(), "hello wor…");
}
#[test]
fn truncate_short() {
assert_eq!(truncate("hello world", 5).as_ref(), "hell…");
}
#[test]
fn truncate_minimal() {
assert_eq!(truncate("hello world", 1).as_ref(), "");
}
#[test]
fn truncate_zero() {
assert_eq!(truncate("hello world", 0).as_ref(), "");
}
#[test]
fn truncate_empty() {
assert_eq!(truncate("", 10).as_ref(), "");
}
#[test]
fn truncate_emoji() {
assert_eq!(truncate("🔥🔥🔥", 5).as_ref(), "🔥🔥…");
}
#[test]
fn truncate_cjk() {
assert_eq!(truncate("あいう", 5).as_ref(), "あい…");
}
#[test]
fn truncate_no_truncation_needed() {
assert_eq!(truncate("hello", 100).as_ref(), "hello");
}
// === pad tests ===
#[test]
fn pad_left() {
assert_eq!(pad("hi", 6, Alignment::Left), "hi ");
}
#[test]
fn pad_right() {
assert_eq!(pad("hi", 6, Alignment::Right), " hi");
}
#[test]
fn pad_center() {
assert_eq!(pad("hi", 6, Alignment::Center), " hi ");
}
#[test]
fn pad_center_odd() {
assert_eq!(pad("hi", 7, Alignment::Center), " hi ");
}
#[test]
fn pad_exact_fit() {
assert_eq!(pad("hi", 2, Alignment::Left), "hi");
}
#[test]
fn pad_too_narrow() {
assert_eq!(pad("hi", 1, Alignment::Left), "hi");
}
#[test]
fn pad_empty() {
assert_eq!(pad("", 5, Alignment::Left), " ");
}
}
+9
View File
@@ -0,0 +1,9 @@
pub mod hint;
pub mod layout;
pub mod render;
pub mod sort;
pub use hint::KeyHint;
pub use layout::GridLayout;
pub use render::{Position, WhichKey};
pub use sort::{default_sort_order, sort_hints, SortField};
+448
View File
@@ -0,0 +1,448 @@
use ratatui::{
buffer::Buffer,
layout::{Alignment, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Padding, Widget},
};
use unicode_width::UnicodeWidthStr;
use crate::hint::KeyHint;
use crate::layout::{grid_layout, pad, truncate};
use crate::sort::{default_sort_order, sort_hints, SortField};
#[derive(Clone, Copy, Debug, Default)]
pub enum Position {
#[default]
BottomLeft,
BottomRight,
BottomCenter,
TopLeft,
TopRight,
TopCenter,
Center,
}
pub struct WhichKey {
hints: Vec<KeyHint>,
title: Option<String>,
position: Position,
separator: String,
group_prefix: String,
column_spacing: usize,
min_column_width: usize,
max_rows: usize,
padding: Padding,
key_style: Style,
separator_style: Style,
desc_style: Style,
group_style: Style,
border_style: Style,
bg: Option<Color>,
}
impl WhichKey {
pub fn new(hints: impl IntoIterator<Item = KeyHint>) -> Self {
let mut hints: Vec<KeyHint> = hints.into_iter().collect();
sort_hints(&mut hints, &default_sort_order());
Self {
hints,
title: None,
position: Position::default(),
separator: "".to_string(),
group_prefix: "+".to_string(),
column_spacing: 2,
min_column_width: 20,
max_rows: 10,
padding: Padding::new(1, 1, 0, 0),
key_style: Style::default().add_modifier(Modifier::BOLD),
separator_style: Style::default().fg(Color::DarkGray),
desc_style: Style::default(),
group_style: Style::default().fg(Color::Cyan),
border_style: Style::default(),
bg: None,
}
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn position(mut self, position: Position) -> Self {
self.position = position;
self
}
pub fn separator(mut self, separator: impl Into<String>) -> Self {
self.separator = separator.into();
self
}
pub fn group_prefix(mut self, prefix: impl Into<String>) -> Self {
self.group_prefix = prefix.into();
self
}
pub fn column_spacing(mut self, spacing: usize) -> Self {
self.column_spacing = spacing;
self
}
pub fn min_column_width(mut self, width: usize) -> Self {
self.min_column_width = width;
self
}
pub fn max_rows(mut self, rows: usize) -> Self {
self.max_rows = rows;
self
}
pub fn padding(mut self, padding: Padding) -> Self {
self.padding = padding;
self
}
pub fn key_style(mut self, style: Style) -> Self {
self.key_style = style;
self
}
pub fn separator_style(mut self, style: Style) -> Self {
self.separator_style = style;
self
}
pub fn desc_style(mut self, style: Style) -> Self {
self.desc_style = style;
self
}
pub fn group_style(mut self, style: Style) -> Self {
self.group_style = style;
self
}
pub fn border_style(mut self, style: Style) -> Self {
self.border_style = style;
self
}
pub fn bg(mut self, color: Color) -> Self {
self.bg = Some(color);
self
}
pub fn sort_fields(mut self, fields: Vec<SortField>) -> Self {
sort_hints(&mut self.hints, &fields);
self
}
fn max_entry_width(&self) -> usize {
let sep_width = UnicodeWidthStr::width(self.separator.as_str());
self.hints
.iter()
.map(|h| {
let key_width = UnicodeWidthStr::width(h.key.as_str());
let desc_width = UnicodeWidthStr::width(h.description.as_str());
key_width + sep_width + desc_width
})
.max()
.unwrap_or(0)
}
fn padding_horizontal(&self) -> u16 {
self.padding.left + self.padding.right
}
fn padding_vertical(&self) -> u16 {
self.padding.top + self.padding.bottom
}
pub fn layout(&self, available: Rect) -> Rect {
if self.hints.is_empty() || available.width == 0 || available.height == 0 {
return Rect::default();
}
let border_size: u16 = 2;
let inner_width = available
.width
.saturating_sub(border_size + self.padding_horizontal());
if inner_width == 0 {
return Rect::default();
}
let max_entry = self.max_entry_width();
let grid = match grid_layout(
self.hints.len(),
max_entry,
inner_width as usize,
self.min_column_width,
self.column_spacing,
) {
Some(g) => g,
None => return Rect::default(),
};
let rows = grid.rows.min(self.max_rows);
let popup_height = (rows as u16)
.saturating_add(border_size)
.saturating_add(self.padding_vertical())
.min(available.height);
let content_width = (grid.column_width * grid.columns) as u16;
let popup_width = content_width
.saturating_add(border_size)
.saturating_add(self.padding_horizontal())
.min(available.width);
let (x, y) = match self.position {
Position::BottomLeft => (available.x, available.bottom().saturating_sub(popup_height)),
Position::BottomRight => (
available.right().saturating_sub(popup_width),
available.bottom().saturating_sub(popup_height),
),
Position::BottomCenter => (
available.x + (available.width.saturating_sub(popup_width)) / 2,
available.bottom().saturating_sub(popup_height),
),
Position::TopLeft => (available.x, available.y),
Position::TopRight => (available.right().saturating_sub(popup_width), available.y),
Position::TopCenter => (
available.x + (available.width.saturating_sub(popup_width)) / 2,
available.y,
),
Position::Center => (
available.x + (available.width.saturating_sub(popup_width)) / 2,
available.y + (available.height.saturating_sub(popup_height)) / 2,
),
};
Rect::new(x, y, popup_width, popup_height)
}
}
impl Widget for &WhichKey {
fn render(self, area: Rect, buf: &mut Buffer) {
if self.hints.is_empty() || area.width == 0 || area.height == 0 {
return;
}
Clear.render(area, buf);
let mut block = Block::default()
.borders(Borders::ALL)
.border_style(self.border_style)
.padding(self.padding);
if let Some(ref title) = self.title {
block = block.title(title.as_str());
}
if let Some(bg) = self.bg {
block = block.style(Style::default().bg(bg));
}
let inner = block.inner(area);
block.render(area, buf);
if inner.width == 0 || inner.height == 0 {
return;
}
let max_entry = self.max_entry_width();
let grid = match grid_layout(
self.hints.len(),
max_entry,
inner.width as usize,
self.min_column_width,
self.column_spacing,
) {
Some(g) => g,
None => return,
};
let sep_width = UnicodeWidthStr::width(self.separator.as_str());
let max_key_width = self
.hints
.iter()
.map(|h| UnicodeWidthStr::width(h.key.as_str()))
.max()
.unwrap_or(0);
for row in 0..grid.rows.min(self.max_rows) {
let y = inner.y + row as u16;
if y >= inner.bottom() {
break;
}
for col in 0..grid.columns {
let Some(idx) = grid.item_index(col, row, self.hints.len()) else {
continue;
};
let hint = &self.hints[idx];
let x = inner.x + (col * grid.column_width) as u16;
if x >= inner.right() {
break;
}
let available_width = (inner.right().saturating_sub(x) as usize)
.min(grid.column_width.saturating_sub(self.column_spacing));
let key_padded = pad(&hint.key, max_key_width, Alignment::Right);
let desc_max_width = available_width
.saturating_sub(max_key_width)
.saturating_sub(sep_width);
let desc_truncated = truncate(&hint.description, desc_max_width);
let desc_style = if hint.is_group {
self.group_style
} else {
self.desc_style
};
let line = Line::from(vec![
Span::styled(&key_padded, self.key_style),
Span::styled(&self.separator, self.separator_style),
Span::styled(desc_truncated.into_owned(), desc_style),
]);
buf.set_line(x, y, &line, available_width as u16);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_layout() {
let w = WhichKey::new(vec![]);
assert_eq!(w.layout(Rect::new(0, 0, 80, 24)), Rect::default());
}
#[test]
fn test_zero_area_layout() {
let w = WhichKey::new(vec![KeyHint::new("a", "test")]);
assert_eq!(w.layout(Rect::new(0, 0, 0, 0)), Rect::default());
}
#[test]
fn test_basic_layout_bottom_left() {
let hints = vec![KeyHint::new("a", "action"), KeyHint::new("b", "buffer")];
let w = WhichKey::new(hints).position(Position::BottomLeft);
let rect = w.layout(Rect::new(0, 0, 80, 24));
assert!(rect.y > 0);
assert_eq!(rect.x, 0);
assert!(rect.width > 0);
assert!(rect.height > 0);
}
#[test]
fn test_render_no_panic_empty() {
let w = WhichKey::new(vec![]);
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 24));
(&w).render(Rect::default(), &mut buf);
}
#[test]
fn test_render_no_panic_zero_area() {
let w = WhichKey::new(vec![KeyHint::new("a", "test")]);
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 24));
(&w).render(Rect::new(0, 0, 0, 0), &mut buf);
}
#[test]
fn test_render_basic() {
let hints = vec![
KeyHint::new("b", "+buffer").group(),
KeyHint::new("h", "help"),
KeyHint::new("q", "quit"),
];
let w = WhichKey::new(hints).title("SPC");
let area = Rect::new(0, 0, 60, 20);
let popup_rect = w.layout(area);
let mut buf = Buffer::empty(area);
(&w).render(popup_rect, &mut buf);
let has_content = (popup_rect.y..popup_rect.bottom()).any(|y| {
(popup_rect.x..popup_rect.right())
.any(|x| buf.cell((x, y)).is_some_and(|c| c.symbol() != " "))
});
assert!(has_content);
}
#[test]
fn test_builder_methods() {
let w = WhichKey::new(vec![KeyHint::new("a", "test")])
.title("Test")
.position(Position::Center)
.separator(" -> ")
.group_prefix("+")
.column_spacing(4)
.min_column_width(15)
.max_rows(5)
.padding(Padding::new(2, 2, 1, 1))
.key_style(Style::default().fg(Color::Red))
.separator_style(Style::default().fg(Color::Blue))
.desc_style(Style::default().fg(Color::Green))
.group_style(Style::default().fg(Color::Yellow))
.border_style(Style::default().fg(Color::White))
.bg(Color::Black);
assert!(w.title.is_some());
assert!(matches!(w.position, Position::Center));
}
#[test]
fn test_sort_fields_builder() {
let hints = vec![
KeyHint::new("G", "last"),
KeyHint::new("g", "goto"),
KeyHint::new("a", "action"),
];
let w = WhichKey::new(hints).sort_fields(vec![SortField::Case]);
assert_eq!(w.hints[0].key, "a");
assert_eq!(w.hints[1].key, "g");
assert_eq!(w.hints[2].key, "G");
}
#[test]
fn test_position_variants() {
let hints = vec![KeyHint::new("a", "action")];
let area = Rect::new(0, 0, 80, 24);
let positions = [
Position::BottomLeft,
Position::BottomRight,
Position::BottomCenter,
Position::TopLeft,
Position::TopRight,
Position::TopCenter,
Position::Center,
];
for pos in positions {
let w = WhichKey::new(hints.clone()).position(pos);
let rect = w.layout(area);
assert!(rect.width > 0);
assert!(rect.height > 0);
assert!(rect.x < area.right());
assert!(rect.y < area.bottom());
}
}
#[test]
fn test_max_rows_clamping() {
let hints: Vec<KeyHint> = (0..20).map(|i| KeyHint::new(format!("{i}"), "desc")).collect();
let w = WhichKey::new(hints).max_rows(5);
let rect = w.layout(Rect::new(0, 0, 80, 50));
assert!(rect.height <= 10);
}
}
+186
View File
@@ -0,0 +1,186 @@
use crate::hint::KeyHint;
#[derive(Clone, Copy, Debug)]
pub enum SortField {
Group,
Alphanum,
Natural,
Case,
}
pub fn default_sort_order() -> Vec<SortField> {
vec![
SortField::Group,
SortField::Alphanum,
SortField::Natural,
SortField::Case,
]
}
pub fn sort_hints(hints: &mut [KeyHint], fields: &[SortField]) {
hints.sort_by(|a, b| {
for field in fields {
let ord = compare_field(a, b, *field);
if ord != std::cmp::Ordering::Equal {
return ord;
}
}
a.key.cmp(&b.key)
});
}
fn compare_field(a: &KeyHint, b: &KeyHint, field: SortField) -> std::cmp::Ordering {
match field {
SortField::Group => {
let a_val = if a.is_group { 1 } else { 0 };
let b_val = if b.is_group { 1 } else { 0 };
a_val.cmp(&b_val)
}
SortField::Alphanum => {
let a_val = if a.key.chars().all(|c| c.is_alphanumeric()) {
0
} else {
1
};
let b_val = if b.key.chars().all(|c| c.is_alphanumeric()) {
0
} else {
1
};
a_val.cmp(&b_val)
}
SortField::Natural => {
let a_natural = natural_key(&a.key);
let b_natural = natural_key(&b.key);
a_natural.cmp(&b_natural)
}
SortField::Case => {
let a_val = if a.key.chars().next().is_some_and(|c| c.is_lowercase()) {
0
} else {
1
};
let b_val = if b.key.chars().next().is_some_and(|c| c.is_lowercase()) {
0
} else {
1
};
a_val.cmp(&b_val)
}
}
}
fn natural_key(s: &str) -> String {
let mut result = String::new();
let mut num_buf = String::new();
for ch in s.chars() {
if ch.is_ascii_digit() {
num_buf.push(ch);
} else {
if !num_buf.is_empty() {
result.push_str(&format!("{:0>9}", num_buf));
num_buf.clear();
}
result.push(ch.to_ascii_lowercase());
}
}
if !num_buf.is_empty() {
result.push_str(&format!("{:0>9}", num_buf));
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sort_groups_last() {
let mut hints = vec![
KeyHint::new("z", "action").group(),
KeyHint::new("a", "cmd"),
KeyHint::new("m", "other"),
];
sort_hints(&mut hints, &default_sort_order());
assert_eq!(hints[0].key, "a");
assert_eq!(hints[1].key, "m");
assert!(hints[2].is_group);
}
#[test]
fn sort_alphanum_before_special() {
let mut hints = vec![
KeyHint::new("<C-d>", "page down"),
KeyHint::new("j", "down"),
KeyHint::new("<Esc>", "escape"),
];
sort_hints(&mut hints, &[SortField::Alphanum]);
assert_eq!(hints[0].key, "j");
}
#[test]
fn sort_natural_numbers() {
let mut hints = vec![
KeyHint::new("F10", "func"),
KeyHint::new("F2", "func"),
KeyHint::new("F1", "func"),
];
sort_hints(&mut hints, &[SortField::Natural]);
assert_eq!(hints[0].key, "F1");
assert_eq!(hints[1].key, "F2");
assert_eq!(hints[2].key, "F10");
}
#[test]
fn sort_case() {
let mut hints = vec![KeyHint::new("G", "last"), KeyHint::new("g", "goto")];
sort_hints(&mut hints, &[SortField::Case]);
assert_eq!(hints[0].key, "g");
assert_eq!(hints[1].key, "G");
}
#[test]
fn sort_combined_default() {
let mut hints = vec![
KeyHint::new("G", "last"),
KeyHint::new("b", "buffer").group(),
KeyHint::new("a", "action"),
KeyHint::new("1", "tab1"),
KeyHint::new("<C-d>", "page down"),
];
sort_hints(&mut hints, &default_sort_order());
assert_eq!(hints[0].key, "1");
assert_eq!(hints[1].key, "a");
assert!(!hints[0].is_group);
assert!(hints.last().unwrap().is_group);
}
#[test]
fn default_sort_order_has_all_fields() {
let order = default_sort_order();
assert_eq!(order.len(), 4);
}
#[test]
fn sort_empty() {
let mut hints: Vec<KeyHint> = vec![];
sort_hints(&mut hints, &default_sort_order());
assert!(hints.is_empty());
}
#[test]
fn sort_single() {
let mut hints = vec![KeyHint::new("a", "action")];
sort_hints(&mut hints, &default_sort_order());
assert_eq!(hints.len(), 1);
assert_eq!(hints[0].key, "a");
}
#[test]
fn natural_key_padding() {
assert_eq!(natural_key("F1"), "f000000001");
assert_eq!(natural_key("F10"), "f000000010");
assert_eq!(natural_key("item123abc"), "item000000123abc");
}
}