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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user