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:
@@ -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"
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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), " ");
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user