use polars::prelude::*;
use ratatui::{
buffer::Buffer,
layout::{Constraint, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Cell, Row, Table, Widget},
};
use crate::search::SearchState;
use super::{SelectionMode, Theme};
const MIN_COL_WIDTH: usize = 3;
const COLUMN_SPACING: usize = 4;
const MAX_COL_FRAC: f32 = 0.3;
const PIN_DIVIDER: usize = 1;
pub struct DataTable<'a> {
pub df: &'a DataFrame,
pub col_offset: usize,
pub cursor_col: usize,
pub row_offset: usize,
pub cursor_row: usize,
pub selection_mode: SelectionMode,
pub theme: &'a Theme,
pub search: Option<&'a SearchState>,
pub search_col: Option<usize>,
pub last_vis_col_out: &'a std::cell::Cell<usize>,
pub sort: &'a [(usize, bool)],
pub sort_tick: Option<usize>,
pub edited: &'a [(usize, usize)],
pub selection: Option<((usize, usize), (usize, usize))>,
pub relative_rows: bool,
pub widths: &'a Widths,
pub pinned: &'a Pinned,
}
fn gutter(abs_row: usize, cursor_row: usize, relative: bool, width: usize) -> String {
let body = if !relative {
format!("{:>width$}", abs_row + 1)
} else if abs_row == cursor_row {
format!("{:<width$}", abs_row + 1)
} else {
format!("{:>width$}", abs_row.abs_diff(cursor_row))
};
format!("{body}│")
}
fn row_num_width(row_offset: usize, viewport_rows: usize) -> usize {
let horizon = (row_offset + viewport_rows * 3).max(99);
let mut p: usize = 10;
while p <= horizon {
p *= 10;
}
(p.to_string().len() - 1) + 2 }
const EMPTY: &str = "\u{b7}";
fn cell_text(value: AnyValue) -> String {
match value {
AnyValue::Null => EMPTY.to_string(),
value => value.str_value().into_owned(),
}
}
fn natural_col_width(col: &Column) -> usize {
let header_w = col.name().chars().count();
let data_w = (0..col.len())
.map(|i| {
col.get(i)
.map(|v| cell_text(v).chars().count())
.unwrap_or(0)
})
.max()
.unwrap_or(0);
header_w.max(data_w).max(MIN_COL_WIDTH)
}
fn sort_marks(order: usize, ascending: bool, keys: usize) -> (String, String) {
let arrow = if ascending { "▲" } else { "▼" };
let full = if keys > 1 {
format!(" [{arrow}{}]", order + 1)
} else {
format!(" {arrow}")
};
(full, arrow.to_string())
}
fn indicator_width(sort: &[(usize, bool)], ci: usize) -> usize {
sort.iter()
.position(|key| key.0 == ci)
.map(|order| sort_marks(order, true, sort.len()).0.chars().count())
.unwrap_or(0)
}
fn header_parts(name: &str, width: usize, marks: Option<(String, String)>) -> (String, String) {
let Some((full, brief)) = marks else {
return (truncate(name, width), String::new());
};
let name_w = name.chars().count();
if name_w + full.chars().count() <= width {
(name.to_string(), full)
} else if name_w + brief.chars().count() <= width {
(name.to_string(), brief)
} else {
let brief_w = brief.chars().count();
(truncate(name, width.saturating_sub(brief_w)), brief)
}
}
fn redistribute(mut widths: Vec<usize>, naturals: &[usize], slack: usize) -> Vec<usize> {
if slack == 0 {
return widths;
}
let mut clipped: Vec<usize> = (0..widths.len())
.filter(|&i| naturals[i] > widths[i])
.collect();
clipped.sort_by_key(|&i| naturals[i]);
let mut rem = slack;
for (order, &ci) in clipped.iter().enumerate() {
let left = clipped.len() - order;
let alloc = rem / left;
let grow = alloc.min(naturals[ci] - widths[ci]);
widths[ci] += grow;
rem -= grow;
}
widths
}
fn highlight_cell(
text: &str,
state: &SearchState,
base_style: Style,
match_style: Style,
) -> Line<'static> {
let mut spans: Vec<Span<'static>> = Vec::new();
let mut last = 0;
for mat in state.query.regex.find_iter(text) {
if mat.start() > last {
spans.push(Span::styled(
text[last..mat.start()].to_string(),
base_style,
));
}
spans.push(Span::styled(
text[mat.start()..mat.end()].to_string(),
match_style,
));
last = mat.end();
}
if last < text.len() {
spans.push(Span::styled(text[last..].to_string(), base_style));
}
if spans.is_empty() {
spans.push(Span::styled(text.to_string(), base_style));
}
Line::from(spans)
}
fn truncate(s: &str, max_chars: usize) -> String {
if s.chars().count() > max_chars {
let t: String = s.chars().take(max_chars.saturating_sub(1)).collect();
format!("{t}…")
} else {
s.to_string()
}
}
pub type Widths = std::collections::HashMap<usize, usize>;
pub type Pinned = std::collections::BTreeSet<usize>;
fn pin_cols(cols: &[Column], pinned: &Pinned) -> Vec<usize> {
pinned
.iter()
.copied()
.filter(|&ci| ci < cols.len())
.collect()
}
fn pin_reserve(cols: &[Column], pins: &[usize], widths: &Widths, max_col: usize) -> usize {
if pins.is_empty() {
return 0;
}
pins.iter()
.map(|&ci| COLUMN_SPACING + width_of(&cols[ci], ci, widths, max_col))
.sum::<usize>()
+ PIN_DIVIDER
}
pub fn pin_fits(
df: &DataFrame,
row_offset: usize,
frame_width: u16,
widths: &Widths,
pinned: &Pinned,
) -> bool {
let cols = df.columns();
let inner_w = (frame_width as usize).saturating_sub(2);
let max_col = ((inner_w as f32 * MAX_COL_FRAC) as usize).max(MIN_COL_WIDTH);
let row_num_w = row_num_width(row_offset, df.height());
let pins = pin_cols(cols, pinned);
let reserve = pin_reserve(cols, &pins, widths, max_col);
row_num_w + reserve + COLUMN_SPACING + MIN_COL_WIDTH <= inner_w
}
fn width_of(column: &Column, source: usize, set: &Widths, cap: usize) -> usize {
match set.get(&source) {
Some(&width) => width.max(MIN_COL_WIDTH),
None => natural_col_width(column).min(cap),
}
}
pub const MIN_COLUMN: usize = MIN_COL_WIDTH;
pub fn natural_width(column: &Column) -> usize {
natural_col_width(column)
}
pub fn drawn_width(column: &Column, source: usize, frame_width: u16, set: &Widths) -> usize {
let inner = (frame_width as usize).saturating_sub(2);
let cap = ((inner as f32 * MAX_COL_FRAC) as usize).max(MIN_COL_WIDTH);
width_of(column, source, set, cap)
}
pub fn col_offset_showing(
df: &DataFrame,
row_offset: usize,
frame_width: u16,
target: usize,
widths: &Widths,
pinned: &Pinned,
) -> usize {
let cols = df.columns();
if cols.is_empty() {
return 0;
}
let target = target.min(cols.len() - 1);
let inner_w = (frame_width as usize).saturating_sub(2);
let max_col = ((inner_w as f32 * MAX_COL_FRAC) as usize).max(MIN_COL_WIDTH);
let row_num_w = row_num_width(row_offset, df.height());
let pins = pin_cols(cols, pinned);
let reserve = pin_reserve(cols, &pins, widths, max_col);
let slot = |ci: usize| COLUMN_SPACING + width_of(&cols[ci], ci, widths, max_col);
let Some(target) = (0..=target).rev().find(|ci| !pins.contains(ci)) else {
return 0;
};
let Some(mut budget) = inner_w
.saturating_sub(row_num_w)
.saturating_sub(reserve)
.checked_sub(slot(target))
else {
return target; };
let mut offset = target;
for ci in (0..target).rev() {
if pins.contains(&ci) {
continue;
}
if budget < slot(ci) {
break;
}
budget -= slot(ci);
offset = ci;
}
offset
}
impl Widget for DataTable<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let cols = self.df.columns();
let inner_w = area.width.saturating_sub(2) as usize;
let sp = COLUMN_SPACING;
let max_col = ((inner_w as f32 * MAX_COL_FRAC) as usize).max(MIN_COL_WIDTH);
let row_num_w = row_num_width(self.row_offset, self.df.height());
let naturals: Vec<usize> = cols
.iter()
.enumerate()
.map(|(index, column)| match self.widths.get(&index) {
Some(&width) => width.max(MIN_COL_WIDTH),
None => natural_col_width(column),
})
.collect();
let pins = pin_cols(cols, self.pinned);
let n_pinned = pins.len();
let mut vis_cols: Vec<usize> = pins.clone();
let mut consumed = row_num_w;
let want = |i: usize| {
if self.widths.contains_key(&i) {
naturals[i]
} else {
naturals[i].min(max_col)
}
};
let mut vis_widths: Vec<usize> = pins.iter().map(|&i| want(i)).collect();
consumed += vis_widths.iter().map(|w| sp + w).sum::<usize>();
if n_pinned > 0 {
consumed += PIN_DIVIDER;
}
for i in self.col_offset..naturals.len() {
if pins.contains(&i) {
continue;
}
let mut w = want(i);
if consumed + sp + w > inner_w {
if vis_cols.len() > n_pinned {
break;
}
w = inner_w.saturating_sub(consumed + sp).max(MIN_COL_WIDTH);
}
vis_cols.push(i);
vis_widths.push(w);
consumed += sp + w;
}
if vis_cols.is_empty() {
return;
}
let scroll_last = vis_cols[n_pinned..].last().copied();
let capped = vis_widths;
let slack = inner_w.saturating_sub(consumed);
let vis_naturals: Vec<usize> = vis_cols
.iter()
.map(|&i| {
if self.widths.contains_key(&i) {
naturals[i]
} else {
naturals[i] + indicator_width(self.sort, i)
}
})
.collect();
let final_widths = redistribute(capped, &vis_naturals, slack);
let used: usize = row_num_w
+ if n_pinned > 0 { PIN_DIVIDER } else { 0 }
+ final_widths.iter().map(|w| sp + w).sum::<usize>();
let remaining = inner_w.saturating_sub(used);
let after = scroll_last.map_or(self.col_offset, |i| i + 1);
let next_col_idx = (after..cols.len())
.find(|ci| !pins.contains(ci))
.unwrap_or(cols.len());
let has_partial = remaining >= sp + MIN_COL_WIDTH && next_col_idx < cols.len();
let partial_width = if has_partial { remaining - sp } else { 0 };
let mut widths: Vec<Constraint> = Vec::with_capacity(vis_cols.len() + 3);
widths.push(Constraint::Length(row_num_w as u16));
for (idx, &w) in final_widths.iter().enumerate() {
widths.push(Constraint::Length((sp + w) as u16));
if idx + 1 == n_pinned {
widths.push(Constraint::Length(PIN_DIVIDER as u16));
}
}
widths.push(Constraint::Min(0));
let hdr_style = Style::new()
.bold()
.fg(self.theme.header)
.add_modifier(Modifier::UNDERLINED);
let col_hdr_style = Style::new()
.bold()
.bg(self.theme.col_cursor_bg)
.fg(self.theme.col_cursor_fg)
.add_modifier(Modifier::UNDERLINED);
let rn_hdr = format!("{:>w$}│", "#", w = row_num_w - 1);
let mut header_cells = vec![Cell::new(rn_hdr).style(hdr_style)];
for (idx, &ci) in vis_cols.iter().enumerate() {
let is_cursor_col = matches!(
self.selection_mode,
SelectionMode::Column | SelectionMode::Cell
) && ci == self.cursor_col;
let cell_style = if is_cursor_col {
col_hdr_style
} else {
hdr_style
};
let fg = if is_cursor_col {
self.theme.col_cursor_fg
} else {
self.theme.header
};
let sort_entry = self.sort.iter().enumerate().find(|(_, key)| key.0 == ci);
let marks = sort_entry.map(|(order, key)| sort_marks(order, key.1, self.sort.len()));
let (name, indicator) =
header_parts(cols[ci].name().as_str(), final_widths[idx], marks.clone());
let cell = if let (Some(tick), Some(_)) = (self.sort_tick, marks.as_ref()) {
let active = Style::new()
.fg(fg)
.bold()
.add_modifier(Modifier::UNDERLINED);
let quiet = Style::new()
.fg(fg)
.add_modifier(Modifier::UNDERLINED)
.remove_modifier(Modifier::BOLD);
let lead = Span::styled(format!("{:>width$}{}", "", name, width = sp), cell_style);
let spans = if indicator.contains('[') {
let bold_pos = (tick / 3) % 3;
let mut chars = indicator.chars();
chars.next(); let bracket = chars.next().map(String::from).unwrap_or_default();
let arrow = chars.next().map(String::from).unwrap_or_default();
let close = chars.next_back().map(String::from).unwrap_or_default();
let num: String = chars.collect();
vec![
lead,
Span::styled(" ", quiet),
Span::styled(bracket, if bold_pos == 0 { active } else { quiet }),
Span::styled(arrow, if bold_pos == 1 { active } else { quiet }),
Span::styled(num, quiet),
Span::styled(close, if bold_pos == 2 { active } else { quiet }),
]
} else {
let on = (tick / 3) % 2 == 0;
vec![
lead,
Span::styled(indicator.clone(), if on { active } else { quiet }),
]
};
Cell::new(Line::from(spans)).style(cell_style)
} else {
let padded = format!("{:>width$}{}{}", "", name, indicator, width = sp);
Cell::new(padded).style(cell_style)
};
header_cells.push(cell);
if idx + 1 == n_pinned {
header_cells.push(Cell::new("│").style(hdr_style));
}
}
if has_partial && next_col_idx < cols.len() {
let name = cols[next_col_idx].name().as_str();
let display = if name.chars().count() >= partial_width {
let clipped: String = name.chars().take(partial_width.saturating_sub(1)).collect();
format!("{clipped}…")
} else {
name.to_string()
};
header_cells.push(Cell::new(format!("{:>sp$}{display}", "")).style(hdr_style));
} else {
header_cells.push(Cell::new("").style(hdr_style));
}
let header = Row::new(header_cells);
let match_style = Style::new().bg(self.theme.match_bg).fg(self.theme.match_fg);
let cursor_style = Style::new()
.bg(self.theme.cursor_bg)
.fg(self.theme.cursor_fg);
let col_cursor_style = Style::new()
.bg(self.theme.col_cursor_bg)
.fg(self.theme.col_cursor_fg);
let selection_style = Style::new()
.bg(self.theme.selection_bg)
.fg(self.theme.selection_fg);
let current_match_row = self.search.and_then(|s| s.current_row());
let rows: Vec<Row> = (0..self.df.height())
.map(|ri| {
let abs_row = self.row_offset + ri;
let is_cursor = abs_row == self.cursor_row;
let is_match_row = current_match_row == Some(abs_row);
let num_style = match self.selection_mode {
SelectionMode::Row if is_cursor => cursor_style,
SelectionMode::Column if is_match_row => cursor_style,
SelectionMode::Column if is_cursor => cursor_style,
_ if is_cursor => Style::new().fg(self.theme.row_num_cursor),
_ => Style::new().fg(self.theme.row_num),
};
let unselected = |ci: usize| -> Style {
let inside = self.selection.is_some_and(|((r0, r1), (c0, c1))| {
(r0..=r1).contains(&abs_row) && (c0..=c1).contains(&ci)
});
if inside {
selection_style
} else {
Style::default()
}
};
let cell_style = |ci: usize| -> Style {
match self.selection_mode {
SelectionMode::Row => {
if is_cursor {
cursor_style
} else {
unselected(ci)
}
}
SelectionMode::Column => {
if is_match_row || is_cursor {
cursor_style
} else if ci == self.cursor_col {
col_cursor_style
} else {
unselected(ci)
}
}
SelectionMode::Cell => {
if is_cursor && ci == self.cursor_col {
cursor_style
} else {
unselected(ci)
}
}
}
};
let rn_str = gutter(abs_row, self.cursor_row, self.relative_rows, row_num_w - 1);
let mut cells = vec![Cell::new(rn_str).style(num_style)];
for (idx, &ci) in vis_cols.iter().enumerate() {
let value = cols[ci].get(ri);
let is_empty = !matches!(&value, Ok(v) if !v.is_null());
let val = match value {
Ok(v) => truncate(&cell_text(v), final_widths[idx]),
Err(_) => EMPTY.to_string(),
};
let cs = cell_style(ci);
let cs = if is_empty {
cs.fg(self.theme.null_fg)
} else {
cs
};
let cs = if self.edited.contains(&(ri, ci)) {
cs.fg(self.theme.edited_fg).add_modifier(Modifier::BOLD)
} else {
cs
};
let search = match self.search_col {
None => self.search,
Some(sc) => {
if ci == sc {
self.search
} else {
None
}
}
};
let cell = if let Some(s) = search {
let pad = Span::styled(format!("{:>width$}", "", width = sp), cs);
let mut spans = vec![pad];
spans.extend(highlight_cell(&val, s, cs, match_style).spans);
Cell::new(Line::from(spans)).style(cs)
} else {
Cell::new(format!("{:>width$}{}", "", val, width = sp)).style(cs)
};
cells.push(cell);
if idx + 1 == n_pinned {
cells.push(Cell::new("│").style(num_style));
}
}
if has_partial && next_col_idx < cols.len() {
let val = match cols[next_col_idx].get(ri) {
Ok(v) => cell_text(v),
Err(_) => EMPTY.to_string(),
};
let display = if val.chars().count() >= partial_width {
let clipped: String =
val.chars().take(partial_width.saturating_sub(1)).collect();
format!("{clipped}…")
} else {
val
};
let display = format!("{:>sp$}{display}", "");
let cs = cell_style(next_col_idx);
let search = match self.search_col {
None => self.search,
Some(sc) => {
if next_col_idx == sc {
self.search
} else {
None
}
}
};
let cell = if let Some(s) = search {
Cell::new(highlight_cell(&display, s, cs, match_style)).style(cs)
} else {
Cell::new(display).style(cs)
};
cells.push(cell);
} else {
cells.push(Cell::new("").style(Style::default()));
}
Row::new(cells)
})
.collect();
self.last_vis_col_out
.set(scroll_last.unwrap_or(self.col_offset));
let border_style = Style::new().fg(self.theme.border);
let block = Block::new()
.borders(Borders::ALL)
.border_style(border_style);
Table::new(rows, widths)
.header(header)
.block(block)
.column_spacing(0)
.render(area, buf);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_gutter_counts_from_the_cursor() {
assert_eq!(gutter(0, 2, true, 3), " 2│");
assert_eq!(gutter(1, 2, true, 3), " 1│");
assert_eq!(gutter(2, 2, true, 3), "3 │");
assert_eq!(gutter(3, 2, true, 3), " 1│");
assert_eq!(gutter(9, 2, true, 3), " 7│");
}
#[test]
fn the_current_line_is_outdented_against_the_distances() {
let cursor = gutter(41, 41, true, 4);
let neighbour = gutter(42, 41, true, 4);
assert_eq!(cursor, "42 │");
assert_eq!(neighbour, " 1│");
assert_eq!(cursor.chars().count(), neighbour.chars().count());
}
#[test]
fn absolute_numbering_right_aligns_every_row() {
assert_eq!(gutter(0, 2, false, 3), " 1│");
assert_eq!(gutter(2, 2, false, 3), " 3│");
assert_eq!(gutter(41, 2, false, 3), " 42│");
}
#[test]
fn a_number_wider_than_the_gutter_still_closes_the_column() {
assert!(gutter(5000, 0, true, 3).ends_with('│'));
assert!(gutter(5000, 0, false, 3).ends_with('│'));
}
fn rendered(cursor_row: usize, relative: bool) -> Vec<String> {
let df = df! {
"name" => ["a", "b", "c", "d", "e"],
"n" => [1, 2, 3, 4, 5],
}
.unwrap();
lines(&draw(&df, cursor_row, relative))
}
fn lines(buf: &Buffer) -> Vec<String> {
(0..buf.area.height)
.map(|y| (0..buf.area.width).map(|x| buf[(x, y)].symbol()).collect())
.collect()
}
fn draw(df: &DataFrame, cursor_row: usize, relative: bool) -> Buffer {
let theme = Theme::catppuccin_mocha();
let last_vis = std::cell::Cell::new(0);
let area = Rect::new(0, 0, 40, 10);
let mut buf = Buffer::empty(area);
DataTable {
df,
col_offset: 0,
cursor_col: 0,
row_offset: 0,
cursor_row,
selection_mode: SelectionMode::Cell,
theme: &theme,
search: None,
search_col: None,
last_vis_col_out: &last_vis,
sort: &[],
sort_tick: None,
edited: &[],
selection: None,
relative_rows: relative,
widths: &Widths::new(),
pinned: &Pinned::new(),
}
.render(area, &mut buf);
buf
}
#[test]
fn the_rendered_gutter_counts_from_the_cursor_row() {
let lines = rendered(2, true);
let gutters: Vec<String> = lines
.iter()
.filter_map(|line| line.split('\u{2502}').nth(1).map(|g| g.trim().to_string()))
.filter(|gutter| !gutter.is_empty()) .collect();
assert_eq!(gutters, ["#", "2", "1", "3", "1", "2"], "{lines:#?}");
}
#[test]
fn the_rendered_gutter_can_be_switched_to_absolute() {
let lines = rendered(2, false);
let gutters: Vec<String> = lines
.iter()
.filter_map(|line| line.split('\u{2502}').nth(1).map(|g| g.trim().to_string()))
.filter(|gutter| !gutter.is_empty()) .collect();
assert_eq!(gutters, ["#", "1", "2", "3", "4", "5"], "{lines:#?}");
}
#[test]
fn text_cells_are_shown_without_the_debug_quoting() {
let lines = rendered(0, true);
let body = lines.join("\n");
assert!(
body.contains(" a ") && !body.contains("\"a\""),
"strings should render bare:\n{body}"
);
}
#[test]
fn cell_text_quotes_nothing_and_still_names_a_null() {
assert_eq!(cell_text(AnyValue::String("alpha")), "alpha");
assert_eq!(cell_text(AnyValue::StringOwned("beta".into())), "beta");
assert_eq!(cell_text(AnyValue::String("1")), "1");
assert_eq!(cell_text(AnyValue::Int64(42)), "42");
assert_eq!(cell_text(AnyValue::Float64(1.5)), "1.5");
assert_eq!(cell_text(AnyValue::Boolean(true)), "true");
assert_eq!(cell_text(AnyValue::Null), EMPTY);
assert_ne!(
cell_text(AnyValue::String("null")),
cell_text(AnyValue::Null)
);
}
#[test]
fn a_column_is_measured_in_characters_not_bytes() {
let df = df! { "n" => ["éàü"] }.unwrap();
assert_eq!(natural_col_width(&df.columns()[0]), MIN_COL_WIDTH.max(3));
}
#[test]
fn quoting_no_longer_pads_the_column_width() {
let df = df! { "s" => ["alpha"] }.unwrap();
assert_eq!(natural_col_width(&df.columns()[0]), 5);
}
fn draw_narrow(df: &DataFrame, width: u16, col_offset: usize) -> Buffer {
draw_pinned(df, width, col_offset, &Pinned::new())
}
fn draw_pinned(df: &DataFrame, width: u16, col_offset: usize, pinned: &Pinned) -> Buffer {
let theme = Theme::catppuccin_mocha();
let last_vis = std::cell::Cell::new(0);
let area = Rect::new(0, 0, width, 4);
let mut buf = Buffer::empty(area);
DataTable {
df,
col_offset,
cursor_col: col_offset,
row_offset: 0,
cursor_row: 0,
selection_mode: SelectionMode::Cell,
theme: &theme,
search: None,
search_col: None,
last_vis_col_out: &last_vis,
sort: &[],
sort_tick: None,
edited: &[],
selection: None,
relative_rows: true,
widths: &Widths::new(),
pinned,
}
.render(area, &mut buf);
buf
}
fn pin_df() -> DataFrame {
df! {
"id" => ["r1"],
"aa" => ["1"],
"bb" => ["2"],
"cc" => ["3"],
"dd" => ["4"],
"ee" => ["5"],
}
.unwrap()
}
#[test]
fn a_pinned_column_stays_on_screen_when_the_view_scrolls_past_it() {
let df = pin_df();
let pins: Pinned = [0].into_iter().collect();
let plain = lines(&draw_narrow(&df, 40, 3))[1].clone();
assert!(!plain.contains("id"), "unpinned it scrolls away: {plain}");
let held = lines(&draw_pinned(&df, 40, 3, &pins))[1].clone();
assert!(held.contains("id"), "pinned it stays: {held}");
assert!(held.contains("cc"), "and the scrolled columns still show");
assert!(
held.find("id") < held.find("cc"),
"at the left edge, ahead of them: {held}"
);
}
#[test]
fn a_pinned_column_that_is_scrolled_to_is_not_drawn_twice() {
let df = pin_df();
let pins: Pinned = [1].into_iter().collect();
let header = lines(&draw_pinned(&df, 40, 0, &pins))[1].clone();
assert_eq!(header.matches("aa").count(), 1, "{header}");
}
#[test]
fn the_pinned_block_is_closed_by_a_divider() {
let df = pin_df();
let pins: Pinned = [0].into_iter().collect();
let drawn = lines(&draw_pinned(&df, 40, 3, &pins))[1].clone();
let chars: Vec<char> = drawn.chars().collect();
let header: String = chars[1..chars.len() - 1].iter().collect();
assert_eq!(header.matches('│').count(), 2, "{header}");
let bar = header.rfind('│').unwrap();
assert!(
header[..bar].contains("id") && !header[bar..].contains("id"),
"the pins sit ahead of the divider and nothing else does: {header}"
);
}
#[test]
fn the_offset_that_shows_a_column_pays_for_the_pinned_block() {
let df = pin_df();
let bare = col_offset_showing(&df, 0, 40, 5, &Widths::new(), &Pinned::new());
let held = col_offset_showing(&df, 0, 40, 5, &Widths::new(), &[0].into_iter().collect());
assert!(held > bare, "bare {bare}, pinned {held}");
}
#[test]
fn a_pinned_target_is_answered_by_the_nearest_scrolling_column() {
let df = pin_df();
let pins: Pinned = [5].into_iter().collect();
assert_eq!(
col_offset_showing(&df, 0, 40, 5, &Widths::new(), &pins),
col_offset_showing(&df, 0, 40, 4, &Widths::new(), &pins),
);
let all: Pinned = (0..6).collect();
assert_eq!(col_offset_showing(&df, 0, 40, 5, &Widths::new(), &all), 0);
}
#[test]
fn a_pinned_block_that_fills_the_screen_does_not_fit() {
let df = pin_df();
assert!(pin_fits(&df, 0, 40, &Widths::new(), &Pinned::new()));
assert!(pin_fits(
&df,
0,
40,
&Widths::new(),
&[0].into_iter().collect()
));
assert!(!pin_fits(&df, 0, 40, &Widths::new(), &(0..6).collect()));
}
fn draw_sorted(df: &DataFrame, width: u16, sort: &[(usize, bool)]) -> Buffer {
let theme = Theme::catppuccin_mocha();
let last_vis = std::cell::Cell::new(0);
let area = Rect::new(0, 0, width, 4);
let mut buf = Buffer::empty(area);
DataTable {
df,
col_offset: 0,
cursor_col: 0,
row_offset: 0,
cursor_row: 0,
selection_mode: SelectionMode::Cell,
theme: &theme,
search: None,
search_col: None,
last_vis_col_out: &last_vis,
sort,
sort_tick: None,
edited: &[],
selection: None,
relative_rows: true,
widths: &Widths::new(),
pinned: &Pinned::new(),
}
.render(area, &mut buf);
buf
}
#[test]
fn a_sorted_column_does_not_lose_its_name_to_the_indicator() {
let df = df! { "region" => ["east"], "q1" => ["1"] }.unwrap();
let header = lines(&draw_sorted(&df, 60, &[(0, true)]))[1].clone();
assert!(header.contains("region"), "{header}");
assert!(header.contains('▲'), "and still says it is sorted: {header}");
}
#[test]
fn a_sorted_column_asks_for_room_for_its_indicator() {
let df = df! { "region" => ["east"], "q1" => ["1"] }.unwrap();
let plain = lines(&draw_sorted(&df, 60, &[]))[1].clone();
let sorted = lines(&draw_sorted(&df, 60, &[(0, true)]))[1].clone();
assert!(!plain.contains('['), "unsorted has no indicator: {plain}");
assert!(sorted.contains("region ▲"), "one key, so no priority: {sorted}");
}
#[test]
fn a_lone_sort_key_is_just_its_arrow() {
assert_eq!(sort_marks(0, true, 1).0, " ▲");
assert_eq!(sort_marks(0, false, 1).0, " ▼");
assert_eq!(
header_parts("region", 8, Some(sort_marks(0, true, 1))),
("region".to_string(), " ▲".to_string())
);
}
#[test]
fn the_priority_number_comes_back_for_a_second_key() {
assert_eq!(sort_marks(0, true, 2).0, " [▲1]");
assert_eq!(sort_marks(2, false, 3).0, " [▼3]");
assert_eq!(
header_parts("q1", 8, Some(sort_marks(2, false, 3))),
("q1".to_string(), " [▼3]".to_string())
);
}
#[test]
fn a_squeezed_indicator_gives_up_its_brackets_before_the_name() {
let marks = || Some(sort_marks(0, true, 3));
assert_eq!(
header_parts("region", 11, marks()),
("region".to_string(), " [▲1]".to_string())
);
assert_eq!(
header_parts("region", 10, marks()),
("region".to_string(), "▲".to_string())
);
assert_eq!(
header_parts("region", 7, marks()),
("region".to_string(), "▲".to_string())
);
let (name, indicator) = header_parts("region", 6, marks());
assert_eq!(indicator, "▲", "the arrow is the part that cannot go");
assert_eq!(name, "regi…", "and the name keeps the rest");
}
#[test]
fn a_lone_arrow_gives_up_its_space_before_the_name() {
let marks = || Some(sort_marks(0, true, 1));
assert_eq!(
header_parts("region", 7, marks()),
("region".to_string(), "▲".to_string())
);
assert_eq!(header_parts("region", 6, marks()).0, "regi…");
}
#[test]
fn an_unsorted_header_is_just_its_name() {
assert_eq!(
header_parts("region", 20, None),
("region".to_string(), String::new())
);
assert_eq!(header_parts("region", 4, None).0, "reg…");
}
#[test]
fn a_partial_column_keeps_its_distance_from_the_last_full_one() {
let names = ["verdict", "shared", "our_tracks", "their_tracks", "matched"];
let df = df! {
"verdict" => ["weak"],
"shared" => ["0.733"],
"our_tracks" => ["15"],
"their_tracks" => ["11"],
"matched" => ["Adele"],
}
.unwrap();
for width in 40..70 {
let rendered = lines(&draw_narrow(&df, width, 0))[1].clone();
let header = rendered.split('\u{2502}').nth(2).unwrap_or("").to_string();
for token in header.split_whitespace() {
let bare = token.trim_end_matches('\u{2026}');
assert!(
names.iter().any(|name| name.starts_with(bare)),
"at width {width}, {token:?} is not a column name: {rendered:?}"
);
}
}
}
#[test]
fn an_empty_cell_is_drawn_recessive() {
let df = df! {
"name" => [Some("a"), None],
"n" => [1, 2],
}
.unwrap();
let buf = draw(&df, 0, true);
let theme = Theme::catppuccin_mocha();
let marker = (0..buf.area.height)
.flat_map(|y| (0..buf.area.width).map(move |x| (x, y)))
.find(|&(x, y)| buf[(x, y)].symbol() == EMPTY)
.expect("the empty cell should be marked");
assert_eq!(buf[marker].fg, theme.null_fg);
assert_ne!(theme.null_fg, theme.cursor_fg, "and not the data colour");
}
#[test]
fn an_empty_column_does_not_reserve_room_for_the_word_null() {
let df = df! { "s" => [None::<&str>, None] }.unwrap();
assert_eq!(natural_col_width(&df.columns()[0]), MIN_COL_WIDTH);
}
#[test]
fn the_gutter_width_is_stable_between_orders_of_magnitude() {
assert_eq!(row_num_width(0, 20), row_num_width(30, 20));
assert!(row_num_width(0, 20) < row_num_width(100_000, 20));
}
}