diff --git a/examples/demo/ui.rs b/examples/demo/ui.rs index 928d96d..b33fe5f 100644 --- a/examples/demo/ui.rs +++ b/examples/demo/ui.rs @@ -244,7 +244,11 @@ where Table::new(header.into_iter(), rows) .block(Block::default().title("Servers").borders(Borders::ALL)) .header_style(Style::default().fg(Color::Yellow)) - .widths(&[15, 15, 10]) + .widths(&[ + Constraint::Length(15), + Constraint::Length(15), + Constraint::Length(10), + ]) .render(f, chunks[0]); Canvas::default() diff --git a/examples/table.rs b/examples/table.rs index 58d9633..70e2250 100644 --- a/examples/table.rs +++ b/examples/table.rs @@ -70,7 +70,11 @@ fn main() -> Result<(), failure::Error> { .split(f.size()); Table::new(header.into_iter(), rows) .block(Block::default().borders(Borders::ALL).title("Table")) - .widths(&[10, 10, 10]) + .widths(&[ + Constraint::Percentage(50), + Constraint::Length(30), + Constraint::Max(10), + ]) .render(&mut f, rects[0]); })?; diff --git a/src/widgets/table.rs b/src/widgets/table.rs index d5b88c6..9b39793 100644 --- a/src/widgets/table.rs +++ b/src/widgets/table.rs @@ -1,8 +1,13 @@ +use std::collections::HashMap; use std::fmt::Display; use std::iter::Iterator; +use cassowary::strength::{MEDIUM, REQUIRED, WEAK}; +use cassowary::WeightedRelation::*; +use cassowary::{Expression, Solver, Variable}; + use crate::buffer::Buffer; -use crate::layout::Rect; +use crate::layout::{Constraint, Rect}; use crate::style::Style; use crate::widgets::{Block, Widget}; @@ -22,6 +27,7 @@ where /// /// ``` /// # use tui::widgets::{Block, Borders, Table, Row}; +/// # use tui::layout::Constraint; /// # use tui::style::{Style, Color}; /// # fn main() { /// let row_style = Style::default().fg(Color::White); @@ -36,7 +42,7 @@ where /// ) /// .block(Block::default().title("Table")) /// .header_style(Style::default().fg(Color::Yellow)) -/// .widths(&[5, 5, 10]) +/// .widths(&[Constraint::Length(5), Constraint::Length(5), Constraint::Length(10)]) /// .style(Style::default().fg(Color::White)) /// .column_spacing(1); /// # } @@ -57,9 +63,8 @@ where header: H, /// Style for the header header_style: Style, - /// Width of each column (if the total width is greater than the widget width some columns may - /// not be displayed) - widths: &'a [u16], + /// Width constraints for each column + widths: &'a [Constraint], /// Space between each column column_spacing: u16, /// Data to display in each row @@ -124,7 +129,16 @@ where self } - pub fn widths(mut self, widths: &'a [u16]) -> Table<'a, T, H, I, D, R> { + pub fn widths(mut self, widths: &'a [Constraint]) -> Table<'a, T, H, I, D, R> { + assert!( + widths.iter().all(|w| { + match w { + Constraint::Percentage(p) => *p <= 100, + _ => true, + } + }), + "Percentages should be between 0 and 100 inclusively." + ); self.widths = widths; self } @@ -169,23 +183,59 @@ where // Set the background self.background(table_area, buf, self.style.bg); - // Save widths of the columns that will fit in the given area - let mut x = 0; - let mut widths = Vec::with_capacity(self.widths.len()); - for width in self.widths.iter() { - if x + width < table_area.width { - widths.push(*width); - } - x += *width; + let mut solver = Solver::new(); + let mut var_indices = HashMap::new(); + let mut ccs = Vec::new(); + let mut variables = Vec::new(); + for i in 0..self.widths.len() { + let var = cassowary::Variable::new(); + variables.push(var); + var_indices.insert(var, i); + } + for (i, constraint) in self.widths.iter().enumerate() { + ccs.push(variables[i] | GE(WEAK) | 0.); + ccs.push(match *constraint { + Constraint::Length(v) => variables[i] | EQ(MEDIUM) | f64::from(v), + Constraint::Percentage(v) => { + variables[i] | EQ(WEAK) | (f64::from(v * area.width) / 100.0) + } + Constraint::Ratio(n, d) => { + variables[i] | EQ(WEAK) | (f64::from(area.width) * f64::from(n) / f64::from(d)) + } + Constraint::Min(v) => variables[i] | GE(WEAK) | f64::from(v), + Constraint::Max(v) => variables[i] | LE(WEAK) | f64::from(v), + }) + } + solver + .add_constraint( + variables + .iter() + .fold(Expression::from_constant(0.), |acc, v| acc + *v) + | LE(REQUIRED) + | f64::from( + area.width - 2 - (self.column_spacing * (variables.len() as u16 - 1)), + ), + ) + .unwrap(); + solver.add_constraints(&ccs).unwrap(); + let mut solved_widths = vec![0; variables.len()]; + for &(var, value) in solver.fetch_changes() { + let index = var_indices[&var]; + let value = if value.is_sign_negative() { + 0 + } else { + value as u16 + }; + solved_widths[index] = value } let mut y = table_area.top(); + let mut x = table_area.left(); // Draw header if y < table_area.bottom() { - x = table_area.left(); - for (w, t) in widths.iter().zip(self.header.by_ref()) { - buf.set_string(x, y, format!("{}", t), self.header_style); + for (w, t) in solved_widths.iter().zip(self.header.by_ref()) { + buf.set_stringn(x, y, format!("{}", t), *w as usize, self.header_style); x += *w + self.column_spacing; } } @@ -201,7 +251,7 @@ where Row::StyledData(d, s) => (d, s), }; x = table_area.left(); - for (w, elt) in widths.iter().zip(data) { + for (w, elt) in solved_widths.iter().zip(data) { buf.set_stringn(x, y + i as u16, format!("{}", elt), *w as usize, style); x += *w + self.column_spacing; } @@ -209,3 +259,16 @@ where } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[should_panic] + fn table_invalid_percentages() { + Table::new([""].iter(), vec![Row::Data([""].iter())].into_iter()) + .widths(&[Constraint::Percentage(110)]); + } + +} diff --git a/tests/table.rs b/tests/table.rs new file mode 100644 index 0000000..ec17167 --- /dev/null +++ b/tests/table.rs @@ -0,0 +1,418 @@ +use tui::backend::TestBackend; +use tui::buffer::Buffer; +use tui::layout::Constraint; +use tui::widgets::{Block, Borders, Row, Table, Widget}; +use tui::Terminal; + +#[test] +fn table_column_spacing() { + let render = |column_spacing| { + let backend = TestBackend::new(30, 10); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal + .draw(|mut f| { + let size = f.size(); + Table::new( + ["Head1", "Head2", "Head3"].iter(), + vec![ + Row::Data(["Row11", "Row12", "Row13"].iter()), + Row::Data(["Row21", "Row22", "Row23"].iter()), + Row::Data(["Row31", "Row32", "Row33"].iter()), + Row::Data(["Row41", "Row42", "Row43"].iter()), + ] + .into_iter(), + ) + .block(Block::default().borders(Borders::ALL)) + .widths(&[ + Constraint::Length(5), + Constraint::Length(5), + Constraint::Length(5), + ]) + .column_spacing(column_spacing) + .render(&mut f, size); + }) + .unwrap(); + terminal.backend().buffer().clone() + }; + + // no space between columns + assert_eq!( + render(0), + Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│Head1Head2Head3 │", + "│ │", + "│Row11Row12Row13 │", + "│Row21Row22Row23 │", + "│Row31Row32Row33 │", + "│Row41Row42Row43 │", + "│ │", + "│ │", + "└────────────────────────────┘", + ]) + ); + + // one space between columns + assert_eq!( + render(1), + Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│Head1 Head2 Head3 │", + "│ │", + "│Row11 Row12 Row13 │", + "│Row21 Row22 Row23 │", + "│Row31 Row32 Row33 │", + "│Row41 Row42 Row43 │", + "│ │", + "│ │", + "└────────────────────────────┘", + ]) + ); + + // enough space to just not hide the third column + assert_eq!( + render(6), + Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│Head1 Head2 Head3 │", + "│ │", + "│Row11 Row12 Row13 │", + "│Row21 Row22 Row23 │", + "│Row31 Row32 Row33 │", + "│Row41 Row42 Row43 │", + "│ │", + "│ │", + "└────────────────────────────┘", + ]) + ); + + // enough space to hide part of the third column + assert_eq!( + render(7), + Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│Head1 Head2 Head│", + "│ │", + "│Row11 Row12 Row1│", + "│Row21 Row22 Row2│", + "│Row31 Row32 Row3│", + "│Row41 Row42 Row4│", + "│ │", + "│ │", + "└────────────────────────────┘", + ]) + ); +} + +#[test] +fn table_widths() { + let render = |widths| { + let backend = TestBackend::new(30, 10); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal + .draw(|mut f| { + let size = f.size(); + Table::new( + ["Head1", "Head2", "Head3"].iter(), + vec![ + Row::Data(["Row11", "Row12", "Row13"].iter()), + Row::Data(["Row21", "Row22", "Row23"].iter()), + Row::Data(["Row31", "Row32", "Row33"].iter()), + Row::Data(["Row41", "Row42", "Row43"].iter()), + ] + .into_iter(), + ) + .block(Block::default().borders(Borders::ALL)) + .widths(widths) + .render(&mut f, size); + }) + .unwrap(); + terminal.backend().buffer().clone() + }; + + // columns of zero width show nothing + assert_eq!( + render(&[ + Constraint::Length(0), + Constraint::Length(0), + Constraint::Length(0) + ]), + Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "└────────────────────────────┘", + ]) + ); + + // columns of 1 width trim + assert_eq!( + render(&[ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1) + ]), + Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│H H H │", + "│ │", + "│R R R │", + "│R R R │", + "│R R R │", + "│R R R │", + "│ │", + "│ │", + "└────────────────────────────┘", + ]) + ); + + // columns of large width just before pushing a column off + assert_eq!( + render(&[ + Constraint::Length(8), + Constraint::Length(8), + Constraint::Length(8) + ]), + Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│Head1 Head2 Head3 │", + "│ │", + "│Row11 Row12 Row13 │", + "│Row21 Row22 Row23 │", + "│Row31 Row32 Row33 │", + "│Row41 Row42 Row43 │", + "│ │", + "│ │", + "└────────────────────────────┘", + ]) + ); +} + +#[test] +fn table_percentage_widths() { + let render = |widths| { + let backend = TestBackend::new(30, 10); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal + .draw(|mut f| { + let size = f.size(); + Table::new( + ["Head1", "Head2", "Head3"].iter(), + vec![ + Row::Data(["Row11", "Row12", "Row13"].iter()), + Row::Data(["Row21", "Row22", "Row23"].iter()), + Row::Data(["Row31", "Row32", "Row33"].iter()), + Row::Data(["Row41", "Row42", "Row43"].iter()), + ] + .into_iter(), + ) + .block(Block::default().borders(Borders::ALL)) + .widths(widths) + .column_spacing(0) + .render(&mut f, size); + }) + .unwrap(); + terminal.backend().buffer().clone() + }; + + // columns of zero width show nothing + assert_eq!( + render(&[ + Constraint::Percentage(0), + Constraint::Percentage(0), + Constraint::Percentage(0) + ]), + Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "└────────────────────────────┘", + ]) + ); + + // columns of not enough width trims the data + assert_eq!( + render(&[ + Constraint::Percentage(10), + Constraint::Percentage(10), + Constraint::Percentage(10) + ]), + Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│HeaHeaHea │", + "│ │", + "│RowRowRow │", + "│RowRowRow │", + "│RowRowRow │", + "│RowRowRow │", + "│ │", + "│ │", + "└────────────────────────────┘", + ]) + ); + + // columns of large width just before pushing a column off + assert_eq!( + render(&[ + Constraint::Percentage(30), + Constraint::Percentage(30), + Constraint::Percentage(30) + ]), + Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│Head1 Head2 Head3 │", + "│ │", + "│Row11 Row12 Row13 │", + "│Row21 Row22 Row23 │", + "│Row31 Row32 Row33 │", + "│Row41 Row42 Row43 │", + "│ │", + "│ │", + "└────────────────────────────┘", + ]) + ); + + // percentages summing to 100 should give equal widths + assert_eq!( + render(&[Constraint::Percentage(50), Constraint::Percentage(50)]), + Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│Head1 Head2 │", + "│ │", + "│Row11 Row12 │", + "│Row21 Row22 │", + "│Row31 Row32 │", + "│Row41 Row42 │", + "│ │", + "│ │", + "└────────────────────────────┘", + ]) + ); +} + +#[test] +fn table_mixed_widths() { + let render = |widths| { + let backend = TestBackend::new(30, 10); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal + .draw(|mut f| { + let size = f.size(); + Table::new( + ["Head1", "Head2", "Head3"].iter(), + vec![ + Row::Data(["Row11", "Row12", "Row13"].iter()), + Row::Data(["Row21", "Row22", "Row23"].iter()), + Row::Data(["Row31", "Row32", "Row33"].iter()), + Row::Data(["Row41", "Row42", "Row43"].iter()), + ] + .into_iter(), + ) + .block(Block::default().borders(Borders::ALL)) + .widths(widths) + .render(&mut f, size); + }) + .unwrap(); + terminal.backend().buffer().clone() + }; + + // columns of zero width show nothing + assert_eq!( + render(&[ + Constraint::Percentage(0), + Constraint::Length(0), + Constraint::Percentage(0) + ]), + Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "└────────────────────────────┘", + ]) + ); + + // columns of not enough width trims the data + assert_eq!( + render(&[ + Constraint::Percentage(10), + Constraint::Length(20), + Constraint::Percentage(10) + ]), + Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│Hea Head2 Hea│", + "│ │", + "│Row Row12 Row│", + "│Row Row22 Row│", + "│Row Row32 Row│", + "│Row Row42 Row│", + "│ │", + "│ │", + "└────────────────────────────┘", + ]) + ); + + // columns of large width just before pushing a column off + assert_eq!( + render(&[ + Constraint::Percentage(30), + Constraint::Length(10), + Constraint::Percentage(30) + ]), + Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│Head1 Head2 Head3 │", + "│ │", + "│Row11 Row12 Row13 │", + "│Row21 Row22 Row23 │", + "│Row31 Row32 Row33 │", + "│Row41 Row42 Row43 │", + "│ │", + "│ │", + "└────────────────────────────┘", + ]) + ); + + // columns of large size (>100% total) hide the last column + assert_eq!( + render(&[ + Constraint::Percentage(60), + Constraint::Length(10), + Constraint::Percentage(60) + ]), + Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│Head1 Head2 │", + "│ │", + "│Row11 Row12 │", + "│Row21 Row22 │", + "│Row31 Row32 │", + "│Row41 Row42 │", + "│ │", + "│ │", + "└────────────────────────────┘", + ]) + ); +}