From 498e92f2e43ad194077cd51080c61663dd6175b4 Mon Sep 17 00:00:00 2001 From: Alexander Date: Sun, 10 May 2026 13:28:27 +0200 Subject: [PATCH] feat(which-key): add passive rendering widget crate with multi-column grid layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/which-key/Cargo.toml | 12 + crates/which-key/src/hint.rs | 96 +++++++ crates/which-key/src/layout.rs | 493 +++++++++++++++++++++++++++++++++ crates/which-key/src/lib.rs | 9 + crates/which-key/src/render.rs | 448 ++++++++++++++++++++++++++++++ crates/which-key/src/sort.rs | 186 +++++++++++++ 6 files changed, 1244 insertions(+) create mode 100644 crates/which-key/Cargo.toml create mode 100644 crates/which-key/src/hint.rs create mode 100644 crates/which-key/src/layout.rs create mode 100644 crates/which-key/src/lib.rs create mode 100644 crates/which-key/src/render.rs create mode 100644 crates/which-key/src/sort.rs diff --git a/crates/which-key/Cargo.toml b/crates/which-key/Cargo.toml new file mode 100644 index 0000000..d1b7b70 --- /dev/null +++ b/crates/which-key/Cargo.toml @@ -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" diff --git a/crates/which-key/src/hint.rs b/crates/which-key/src/hint.rs new file mode 100644 index 0000000..4deb18d --- /dev/null +++ b/crates/which-key/src/hint.rs @@ -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", "") + 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, desc: impl Into) -> 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); + } +} diff --git a/crates/which-key/src/layout.rs b/crates/which-key/src/layout.rs new file mode 100644 index 0000000..fe72317 --- /dev/null +++ b/crates/which-key/src/layout.rs @@ -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, max: Option }, +} + +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 { + 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 { + 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), " "); + } +} diff --git a/crates/which-key/src/lib.rs b/crates/which-key/src/lib.rs new file mode 100644 index 0000000..2aaa616 --- /dev/null +++ b/crates/which-key/src/lib.rs @@ -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}; diff --git a/crates/which-key/src/render.rs b/crates/which-key/src/render.rs new file mode 100644 index 0000000..0b37f63 --- /dev/null +++ b/crates/which-key/src/render.rs @@ -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, + title: Option, + 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, +} + +impl WhichKey { + pub fn new(hints: impl IntoIterator) -> Self { + let mut hints: Vec = 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) -> 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) -> Self { + self.separator = separator.into(); + self + } + + pub fn group_prefix(mut self, prefix: impl Into) -> 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) -> 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 = (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); + } +} diff --git a/crates/which-key/src/sort.rs b/crates/which-key/src/sort.rs new file mode 100644 index 0000000..738d553 --- /dev/null +++ b/crates/which-key/src/sort.rs @@ -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 { + 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("", "page down"), + KeyHint::new("j", "down"), + KeyHint::new("", "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("", "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 = 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"); + } +}