diff --git a/src/cli/render.rs b/src/cli/render.rs index 97bf896..2f7879b 100644 --- a/src/cli/render.rs +++ b/src/cli/render.rs @@ -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>, alignments: Vec, max_widths: Vec>, + flex_col_idx: Option, 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::() + + 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]