Files
ui-agregator/crates/which-key/src/layout.rs
T
Alexander 498e92f2e4 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>
2026-05-10 13:28:27 +02:00

494 lines
12 KiB
Rust

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), " ");
}
}