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