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