From 94877f4e7e974d15fa3d64b02f7886e8d8e52af2 Mon Sep 17 00:00:00 2001 From: Jeffas Date: Wed, 10 Jul 2019 11:39:10 +0100 Subject: [PATCH] Use constraints for table column widths This allows table column widths to be adapted more and scale with the UI. The constraints are solved using the Cassowary solver. An added constraint for fitting them all in the width is added. --- examples/demo/ui.rs | 6 +- examples/table.rs | 6 +- src/widgets/table.rs | 99 ++++++++-- tests/table.rs | 418 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 509 insertions(+), 20 deletions(-) create mode 100644 tests/table.rs 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 │", + "│ │", + "│ │", + "└────────────────────────────┘", + ]) + ); +}