use std::cmp::Ordering;
use chrono::NaiveDate;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use super::{Align, Column, ColumnKind, FilterScope, Row, SEPARATOR, SortDir};
use crate::fuzzy;
pub(super) fn allocate_columns(
specs: &[(usize, usize)],
avail: usize,
) -> Vec<usize> {
let mut widths: Vec<usize> = specs.iter().map(|&(min, _)| min).collect();
let separators = SEPARATOR.width() * specs.len().saturating_sub(1);
let used: usize = widths.iter().sum::<usize>() + separators;
let mut leftover = avail.saturating_sub(used);
while leftover > 0 {
let mut progressed = false;
for (width, &(_, target)) in widths.iter_mut().zip(specs) {
if leftover == 0 {
break;
}
if *width < target {
*width += 1;
leftover -= 1;
progressed = true;
}
}
if !progressed {
break;
}
}
widths
}
pub(super) fn wrap_cell(text: &str, width: usize) -> Vec<String> {
if width == 0 {
return vec![String::new()];
}
let mut lines: Vec<String> = Vec::new();
let mut current = String::new();
let mut used = 0usize;
for ch in text.chars() {
let char_width = ch.width().unwrap_or(0);
if used + char_width > width && !current.is_empty() {
lines.push(std::mem::take(&mut current));
used = 0;
}
current.push(ch);
used += char_width;
}
if !current.is_empty() || lines.is_empty() {
lines.push(current);
}
lines
}
fn compare_cells(kind: ColumnKind, a: &str, b: &str) -> Ordering {
match kind {
ColumnKind::Number => {
let x = a.trim().parse::<f64>().unwrap_or(f64::NEG_INFINITY);
let y = b.trim().parse::<f64>().unwrap_or(f64::NEG_INFINITY);
x.partial_cmp(&y).unwrap_or(Ordering::Equal)
}
ColumnKind::Date => {
let parse =
|s: &str| NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d").ok();
parse(a).cmp(&parse(b))
}
ColumnKind::Text => a.cmp(b),
}
}
pub(super) fn sort_indices(
rows: &[Row],
columns: &[Column],
mut indices: Vec<usize>,
col: usize,
dir: SortDir,
) -> Vec<usize> {
let kind = columns.get(col).map_or(ColumnKind::Text, |c| c.kind);
indices.sort_by(|&a, &b| {
let order = compare_cells(kind, rows[a].cell(col), rows[b].cell(col));
match dir {
SortDir::Asc => order,
SortDir::Desc => order.reverse(),
}
});
indices
}
pub(super) fn filter_indices(
rows: &[Row],
query: &str,
scope: FilterScope,
active_col: usize,
) -> Vec<usize> {
fuzzy::rank_by(rows, query, |row| match scope {
FilterScope::AllColumns => row.cells.join(" ").into(),
FilterScope::ActiveColumn => row.cell(active_col).into(),
})
}
pub(super) fn visible_offset(
heights: &[u16],
cursor: usize,
area_h: u16,
prev: usize,
) -> usize {
if heights.is_empty() || area_h == 0 {
return 0;
}
let mut off = prev.min(cursor).min(heights.len() - 1);
while off < cursor {
let used: u16 = heights[off..=cursor].iter().copied().sum();
if used <= area_h {
break;
}
off += 1;
}
off
}
pub(super) fn pad_align(text: &str, width: usize, align: Align) -> String {
let pad = width.saturating_sub(text.width());
match align {
Align::Left => format!("{text}{}", " ".repeat(pad)),
Align::Right => format!("{}{text}", " ".repeat(pad)),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn row(cells: &[&str]) -> Row {
Row::new(cells.iter().map(|c| (*c).to_string()).collect())
}
fn rows(table: &[&[&str]]) -> Vec<Row> {
table.iter().map(|cells| row(cells)).collect()
}
#[test]
fn every_column_gets_at_least_its_minimum() {
let widths = allocate_columns(&[(5, 20), (5, 20), (5, 20)], 4);
assert_eq!(widths, vec![5, 5, 5]);
}
#[test]
fn leftover_room_is_shared_round_robin_up_to_the_target() {
let widths = allocate_columns(&[(2, 10), (2, 10), (2, 10)], 15);
assert_eq!(widths.iter().sum::<usize>(), 2 * 3 + 5);
let spread =
widths.iter().max().unwrap() - widths.iter().min().unwrap();
assert!(spread <= 1, "not shared evenly: {widths:?}");
}
#[test]
fn a_column_never_grows_past_its_target() {
let widths = allocate_columns(&[(1, 2), (1, 100)], 60);
assert_eq!(widths[0], 2);
assert!(widths[1] > 2);
}
#[test]
fn wrap_cell_breaks_on_the_display_width() {
assert_eq!(wrap_cell("abcdef", 3), vec!["abc", "def"]);
}
#[test]
fn wrap_cell_measures_wide_glyphs_as_two_columns() {
assert_eq!(
wrap_cell("\u{4f60}\u{597d}", 3),
vec!["\u{4f60}", "\u{597d}"]
);
}
#[test]
fn wrap_cell_yields_one_empty_line_for_a_zero_width() {
assert_eq!(wrap_cell("abc", 0), vec![String::new()]);
}
#[test]
fn wrap_cell_keeps_an_empty_text_as_one_empty_line() {
assert_eq!(wrap_cell("", 5), vec![String::new()]);
}
#[test]
fn numbers_sort_numerically_not_lexically() {
let data = rows(&[&["9"], &["10"], &["2"]]);
let columns = vec![Column::number("n")];
let sorted =
sort_indices(&data, &columns, vec![0, 1, 2], 0, SortDir::Asc);
assert_eq!(sorted, vec![2, 0, 1], "lexical order would be 10, 2, 9");
}
#[test]
fn an_unparseable_number_sorts_to_the_bottom_edge() {
let data = rows(&[&["3"], &["n/a"], &["1"]]);
let columns = vec![Column::number("n")];
let sorted =
sort_indices(&data, &columns, vec![0, 1, 2], 0, SortDir::Asc);
assert_eq!(sorted, vec![1, 2, 0]);
}
#[test]
fn dates_sort_chronologically() {
let data = rows(&[&["2026-01-09"], &["2026-01-10"], &["2025-12-31"]]);
let columns = vec![Column::date("d")];
let sorted =
sort_indices(&data, &columns, vec![0, 1, 2], 0, SortDir::Asc);
assert_eq!(sorted, vec![2, 0, 1]);
}
#[test]
fn descending_reverses_the_ascending_order() {
let data = rows(&[&["b"], &["a"], &["c"]]);
let columns = vec![Column::text("t")];
let asc = sort_indices(&data, &columns, vec![0, 1, 2], 0, SortDir::Asc);
let desc =
sort_indices(&data, &columns, vec![0, 1, 2], 0, SortDir::Desc);
let mut reversed = asc.clone();
reversed.reverse();
assert_eq!(desc, reversed);
}
#[test]
fn sorting_an_unknown_column_is_a_no_op() {
let data = rows(&[&["b"], &["a"]]);
let sorted = sort_indices(&data, &[], vec![0, 1], 7, SortDir::Asc);
assert_eq!(sorted, vec![0, 1]);
}
#[test]
fn equal_cells_keep_their_previous_order() {
let data = rows(&[&["same", "first"], &["same", "second"]]);
let columns = vec![Column::text("t"), Column::text("u")];
let sorted = sort_indices(&data, &columns, vec![1, 0], 0, SortDir::Asc);
assert_eq!(sorted, vec![1, 0]);
}
#[test]
fn an_empty_filter_keeps_every_row_in_order() {
let data = rows(&[&["alpha"], &["beta"]]);
let kept = filter_indices(&data, " ", FilterScope::AllColumns, 0);
assert_eq!(kept, vec![0, 1]);
}
#[test]
fn the_active_column_scope_ignores_the_other_columns() {
let data = rows(&[&["alpha", "zzz"], &["beta", "match"]]);
let all = filter_indices(&data, "match", FilterScope::AllColumns, 0);
assert_eq!(all, vec![1]);
let scoped =
filter_indices(&data, "match", FilterScope::ActiveColumn, 0);
assert!(scoped.is_empty(), "column 0 holds no match");
}
#[test]
fn the_offset_stays_put_while_the_cursor_is_visible() {
let heights = [1, 1, 1, 1, 1];
assert_eq!(visible_offset(&heights, 2, 5, 0), 0);
}
#[test]
fn the_offset_advances_just_far_enough_to_reveal_the_cursor() {
let heights = [1, 1, 1, 1, 1];
assert_eq!(visible_offset(&heights, 4, 3, 0), 2);
}
#[test]
fn a_taller_row_pushes_the_offset_further_down() {
let heights = [3, 3, 1];
assert_eq!(visible_offset(&heights, 2, 4, 0), 1);
}
#[test]
fn an_empty_table_or_zero_height_yields_offset_zero() {
assert_eq!(visible_offset(&[], 3, 10, 5), 0);
assert_eq!(visible_offset(&[1, 1], 1, 0, 1), 0);
}
#[test]
fn pad_align_pads_on_the_side_opposite_the_alignment() {
assert_eq!(pad_align("ab", 5, Align::Left), "ab ");
assert_eq!(pad_align("ab", 5, Align::Right), " ab");
}
#[test]
fn pad_align_leaves_overlong_text_untouched() {
assert_eq!(pad_align("abcdef", 3, Align::Left), "abcdef");
}
}