498e92f2e4
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>
494 lines
12 KiB
Rust
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), " ");
|
|
}
|
|
}
|