use plurimus::core::ratatui_core::layout::Constraint;
use plurimus::core::ratatui_core::text::Line;
use plurimus::widgets::{TableColumns, TableLayout};
use super::cells::Cell;
use super::sort;
use super::table::DomainTable;
use crate::commands::tui::layout::{self, cells_of, clipped};
const SPARE: u16 = 8;
const FLOOR: u16 = 6;
struct Dealt {
width: u16,
header: String,
is_numeric: bool,
}
pub struct Laid(Vec<Dealt>);
impl Laid {
pub fn of(table: &DomainTable, rows: &[sort::Item], given: u16) -> Self {
let fields = table.ops.fields;
let mut columns: Vec<Dealt> = table
.list
.columns
.iter()
.enumerate()
.map(|(at, column)| {
let header = heading(column.header(fields), table.sort, at);
Dealt {
width: widest(&header, rows, at),
header,
is_numeric: column.is_numeric(fields),
}
})
.collect();
let shared = share(&columns, given);
for (column, width) in columns.iter_mut().zip(shared) {
column.width = width;
}
Self(columns)
}
pub fn columns(&self) -> TableColumns {
let widths = self.0.iter().map(|column| Constraint::Length(column.width));
TableColumns(widths.collect())
}
pub fn header(&self) -> Vec<Line<'static>> {
let headers = self.0.iter().map(|column| column.header.clone());
self.lines(headers)
}
pub fn row(&self, cells: Vec<Cell>) -> Vec<Line<'static>> {
self.lines(cells.into_iter().map(|cell| cell.text))
}
fn lines(&self, texts: impl Iterator<Item = String>) -> Vec<Line<'static>> {
self.0
.iter()
.zip(texts)
.map(|(column, text)| {
let line = Line::from(clipped(text, column.width));
if column.is_numeric {
line.right_aligned()
} else {
line
}
})
.collect()
}
}
fn heading(label: &str, sort: Option<super::sort::Sort>, at: usize) -> String {
match sort.and_then(|sort| sort.marks(at)) {
Some(glyph) => format!("{label} {glyph}"),
None => label.to_owned(),
}
}
fn widest(header: &str, rows: &[sort::Item], at: usize) -> u16 {
let cells = rows.iter().filter_map(|(_, cells)| cells.get(at));
cells.fold(cells_of(header), |widest, cell| {
widest.max(cells_of(&cell.text))
})
}
fn share(columns: &[Dealt], given: u16) -> Vec<u16> {
let gaps = u16::try_from(columns.len().saturating_sub(1)).unwrap_or_default();
let spacing = gaps.saturating_mul(TableLayout::default().column_spacing);
let usable = given.saturating_sub(layout::CURSOR_COLS.saturating_add(spacing));
let wanted = columns
.iter()
.fold(0u16, |total, column| total.saturating_add(column.width));
match usable.checked_sub(wanted) {
Some(spare) => widened(columns, spare),
None => squeezed(columns, usable),
}
}
fn widened(columns: &[Dealt], spare: u16) -> Vec<u16> {
let words = columns.iter().filter(|column| !column.is_numeric).count();
let each = match u16::try_from(words) {
Ok(words) if words > 0 => (spare / words).min(SPARE),
_ => 0,
};
let given = |column: &Dealt| column.width + u16::from(!column.is_numeric) * each;
columns.iter().map(given).collect()
}
fn squeezed(columns: &[Dealt], usable: u16) -> Vec<u16> {
let numbers = columns
.iter()
.filter(|column| column.is_numeric)
.fold(0u16, |total, column| total.saturating_add(column.width));
let left = usable.saturating_sub(numbers);
let name = columns.iter().position(|column| !column.is_numeric);
let others: Vec<u16> = columns
.iter()
.enumerate()
.filter(|(at, column)| !column.is_numeric && Some(*at) != name)
.map(|(_, column)| column.width)
.collect();
let floors = others
.iter()
.fold(0u16, |total, width| total.saturating_add(FLOOR.min(*width)));
let kept = name.map_or(0, |at| {
floored(columns[at].width, left.saturating_sub(floors))
});
let level = level(others, left.saturating_sub(kept));
let given = |(at, column): (usize, &Dealt)| {
if column.is_numeric {
column.width
} else if Some(at) == name {
kept
} else {
floored(column.width, level)
}
};
columns.iter().enumerate().map(given).collect()
}
fn floored(wanted: u16, room: u16) -> u16 {
room.clamp(FLOOR.min(wanted), wanted)
}
fn level(mut widths: Vec<u16>, mut room: u16) -> u16 {
widths.sort_unstable();
for (at, width) in widths.iter().enumerate() {
let sharing = u16::try_from(widths.len() - at).unwrap_or(u16::MAX);
let share = room / sharing;
if *width > share {
return share;
}
room -= width;
}
u16::MAX
}
#[cfg(test)]
mod tests {
use super::*;
const GAP: u16 = 1;
fn column(wanted: u16, is_numeric: bool) -> Dealt {
Dealt {
width: wanted,
header: String::new(),
is_numeric,
}
}
fn word(wanted: u16) -> Dealt {
column(wanted, false)
}
fn number(wanted: u16) -> Dealt {
column(wanted, true)
}
#[test]
fn a_table_with_room_gives_every_column_what_it_holds() {
let columns = [word(10), number(8)];
let given = 10 + 8 + layout::CURSOR_COLS + GAP;
assert_eq!(share(&columns, given), [10, 8]);
}
#[test]
fn the_spare_goes_to_the_words_and_is_capped() {
let columns = [word(10), number(8)];
let roomy = 10 + 8 + layout::CURSOR_COLS + GAP + 4;
assert_eq!(share(&columns, roomy), [14, 8], "the spare widens the word");
let vast = roomy + 400;
assert_eq!(
share(&columns, vast),
[10 + SPARE, 8],
"and stops at the cap"
);
}
#[test]
fn a_squeeze_takes_from_the_words_and_never_from_the_numbers() {
let columns = [word(20), word(10), number(8)];
let tight = 8 + 20 + layout::CURSOR_COLS + GAP * 2;
let shared = share(&columns, tight);
assert_eq!(shared[2], 8, "the figure keeps every digit");
assert!(shared[0] < 20 && shared[1] < 10, "{shared:?}");
assert!(shared[0] > shared[1], "the wider column keeps more");
}
#[test]
fn a_squeeze_keeps_the_name_and_cuts_the_widest_of_the_rest() {
let columns = [word(12), number(8), word(20), word(18), word(7)];
let given = 12 + 8 + 12 + 12 + 7 + layout::CURSOR_COLS + GAP * 4;
assert_eq!(
share(&columns, given),
[12, 8, 12, 12, 7],
"the two long columns meet, and the short one is left alone"
);
}
#[test]
fn a_squeezed_column_stops_at_the_floor() {
let columns = [word(30), number(40)];
let shared = share(&columns, 44);
assert_eq!(shared, [FLOOR, 40]);
}
#[test]
fn a_column_narrower_than_the_floor_is_not_widened_by_it() {
let columns = [word(3), number(40)];
assert_eq!(share(&columns, 44), [3, 40], "a short word stays short");
}
}