feat(render): add flex-column support to TableBuilder

Add flex_width() helper and flex_col() builder method so a designated
column can absorb remaining terminal width after fixed columns are sized.
The flex column's width is clamped between 20 chars and its natural
(content-driven) width, and max_width constraints are skipped for it.
This commit is contained in:
teernisse
2026-03-13 11:01:12 -04:00
parent 3fed5a3048
commit ef8a316372

View File

@@ -619,6 +619,12 @@ pub fn truncate_pad(s: &str, width: usize) -> String {
}
}
/// Compute available width for a flexible field given overhead chars.
/// Returns `terminal_width - overhead`, clamped to `[min, terminal_width]`.
pub fn flex_width(overhead: usize, min: usize) -> usize {
terminal_width().saturating_sub(overhead).max(min)
}
/// Word-wrap text to `width`, prepending `indent` to continuation lines.
/// Returns a single string with embedded newlines.
pub fn wrap_indent(text: &str, width: usize, indent: &str) -> String {
@@ -811,6 +817,7 @@ pub struct Table {
rows: Vec<Vec<StyledCell>>,
alignments: Vec<Align>,
max_widths: Vec<Option<usize>>,
flex_col_idx: Option<usize>,
col_count: usize,
indent: usize,
}
@@ -864,6 +871,14 @@ impl Table {
self
}
/// Designate one column as flexible: it absorbs remaining terminal width
/// after fixed-width columns are sized. Replaces both `max_width()` constraints
/// and pre-truncation for that column.
pub fn flex_col(mut self, col: usize) -> Self {
self.flex_col_idx = Some(col);
self
}
/// Render the table to a string.
pub fn render(&self) -> String {
let col_count = self.col_count;
@@ -889,15 +904,34 @@ impl Table {
}
}
// Apply max_width constraints
// Apply max_width constraints (for non-flex columns)
for (i, max_w) in self.max_widths.iter().enumerate() {
if let Some(max) = max_w
&& i < widths.len()
&& self.flex_col_idx != Some(i)
{
widths[i] = widths[i].min(*max);
}
}
// Apply flex_col: the flex column absorbs remaining terminal width
if let Some(fc) = self.flex_col_idx
&& fc < widths.len()
{
let gap_total = gap.len() * col_count.saturating_sub(1);
let fixed_sum: usize = widths
.iter()
.enumerate()
.filter(|(i, _)| *i != fc)
.map(|(_, w)| w)
.sum::<usize>()
+ gap_total
+ self.indent;
let available = terminal_width().saturating_sub(fixed_sum);
let natural = widths[fc];
widths[fc] = available.clamp(20, natural);
}
let mut out = String::new();
// Header row + separator (only when headers are set)
@@ -1345,6 +1379,69 @@ mod tests {
assert!(plain.contains("Bob"), "missing Bob: {plain}");
}
#[test]
fn table_flex_col_absorbs_remaining_width() {
// Simulate a narrow terminal by using COLUMNS env
let saved = std::env::var("COLUMNS").ok();
unsafe { std::env::set_var("COLUMNS", "60") };
let mut table = Table::new()
.headers(&["ID", "Title", "State"])
.flex_col(1);
table.add_row(vec![
StyledCell::plain("1"),
StyledCell::plain("A very long title that would normally extend beyond the terminal width"),
StyledCell::plain("open"),
]);
let result = table.render();
let plain = strip_ansi(&result);
// The title should be truncated (flex col constrains it)
assert!(
plain.contains("..."),
"expected flex col truncation in: {plain}"
);
// The full title should NOT appear
assert!(
!plain.contains("beyond the terminal width"),
"flex col should have truncated title: {plain}"
);
// Restore
match saved {
Some(v) => unsafe { std::env::set_var("COLUMNS", v) },
None => unsafe { std::env::remove_var("COLUMNS") },
}
}
#[test]
fn table_flex_col_does_not_truncate_when_fits() {
let saved = std::env::var("COLUMNS").ok();
unsafe { std::env::set_var("COLUMNS", "120") };
let mut table = Table::new()
.headers(&["ID", "Title", "State"])
.flex_col(1);
table.add_row(vec![
StyledCell::plain("1"),
StyledCell::plain("Short title"),
StyledCell::plain("open"),
]);
let result = table.render();
let plain = strip_ansi(&result);
// Short title should appear in full
assert!(
plain.contains("Short title"),
"short title should not be truncated: {plain}"
);
match saved {
Some(v) => unsafe { std::env::set_var("COLUMNS", v) },
None => unsafe { std::env::remove_var("COLUMNS") },
}
}
#[test]
fn table_missing_cells_padded() {
let mut table = Table::new().headers(&["A", "B", "C"]);
@@ -1356,6 +1453,26 @@ mod tests {
assert!(plain.contains("1"), "got: {plain}");
}
#[test]
fn flex_width_respects_terminal() {
let saved = std::env::var("COLUMNS").ok();
unsafe { std::env::set_var("COLUMNS", "100") };
// With 30 overhead on a 100-col terminal, should get 70
assert_eq!(flex_width(30, 20), 70);
// With 90 overhead, should get min of 20 (not 10)
assert_eq!(flex_width(90, 20), 20);
// With 50 overhead, should get 50
assert_eq!(flex_width(50, 20), 50);
match saved {
Some(v) => unsafe { std::env::set_var("COLUMNS", v) },
None => unsafe { std::env::remove_var("COLUMNS") },
}
}
// ── GlyphMode ──
#[test]