Repository: zhiburt/tabled
Pull request #77 (2022-03-08 22:44:37)

Add TotalWidth support


Patch:
diff --git a/Cargo.toml b/Cargo.toml
index b4d35c6..321c991 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -13,7 +13,7 @@ categories = ["text-processing", "visualization"]
 readme = "README.md"
 
 [workspace]
-members = ["papergrid", "tabled_derive"]
+members = ["papergrid", "tabled_derive", "examples/terminal_table"]
 
 [badges]
 travis-ci = { repository = "https://github.com/zhiburt/tabled", branch = "master" }
diff --git a/examples/terminal_table/Cargo.toml b/examples/terminal_table/Cargo.toml
new file mode 100644
index 0000000..13f8bb1
--- /dev/null
+++ b/examples/terminal_table/Cargo.toml
@@ -0,0 +1,9 @@
+[package]
+name = "terminal_table"
+version = "0.1.0"
+edition = "2021"
+publish = false
+
+[dependencies]
+terminal_size = "0.1.17"
+tabled = {path = "../.."}
\ No newline at end of file
diff --git a/examples/terminal_table/src/main.rs b/examples/terminal_table/src/main.rs
new file mode 100644
index 0000000..63b583b
--- /dev/null
+++ b/examples/terminal_table/src/main.rs
@@ -0,0 +1,45 @@
+//! The example can be run by this command
+//! `cargo run --example terminal_table`
+
+use tabled::{object::Full, Alignment, Modify, Style, TableIteratorExt, Tabled, TotalWidth};
+
+#[derive(Tabled)]
+struct Release {
+    version: &'static str,
+    published_date: &'static str,
+    is_active: bool,
+    major_feature: &'static str,
+}
+
+const DATA: [Release; 3] = [
+    Release {
+        version: "0.2.1",
+        published_date: "2021-06-23",
+        is_active: true,
+        major_feature: "#[header(inline)] attribute",
+    },
+    Release {
+        version: "0.2.0",
+        published_date: "2021-06-19",
+        is_active: false,
+        major_feature: "API changes",
+    },
+    Release {
+        version: "0.1.4",
+        published_date: "2021-06-07",
+        is_active: false,
+        major_feature: "display_with attribute",
+    },
+];
+
+fn main() {
+    let (terminal_size::Width(width), _) = terminal_size::terminal_size().unwrap();
+
+    let table = DATA
+        .table()
+        .with(Style::extended())
+        .with(Modify::new(Full).with(Alignment::center_horizontal()))
+        .with(TotalWidth::new(width as usize).wrap(true));
+
+    println!("{}", table);
+}
diff --git a/papergrid/src/lib.rs b/papergrid/src/lib.rs
index 3ada5c6..7084dab 100644
--- a/papergrid/src/lib.rs
+++ b/papergrid/src/lib.rs
@@ -436,10 +436,81 @@ impl Grid {
         new_grid
     }
 
+    /// Returns a total width of table, including split lines.
+    pub fn total_width(&self) -> usize {
+        // can be simplified? by just getting a split line chars().count()
+
+        let count_rows = self.count_rows();
+        let count_columns = self.count_columns();
+        let mut cells = self.collect_cells(count_rows, count_columns);
+        let mut styles = self.collect_styles(count_rows, count_columns);
+        let split_borders = (0..count_rows)
+            .map(|row| self.get_inner_split_line(row))
+            .collect::<Vec<_>>();
+
+        let widths = columns_width(
+            &mut cells,
+            &mut styles,
+            &split_borders,
+            count_rows,
+            count_columns,
+        );
+
+        let content_width = widths
+            .into_iter()
+            .next()
+            .map(|row| row.into_iter().sum::<usize>())
+            .unwrap_or(0);
+
+        let count_borders = split_borders
+            .into_iter()
+            .next()
+            .map(|row| {
+                let left_border = row
+                    .get(0)
+                    .map_or(0, |b| if b.connector1.is_some() { 1 } else { 0 });
+                let other_borders = row
+                    .into_iter()
+                    .enumerate()
+                    .filter(|&(col, _)| is_cell_visible(&styles[0], col))
+                    .filter(|(_, b)| b.connector2.is_some())
+                    .count();
+
+                left_border + other_borders
+            })
+            .unwrap_or(0);
+
+        content_width + count_borders
+    }
+
     pub fn override_split_line(&mut self, row: usize, line: impl Into<String>) {
         self.override_split_lines.insert(row, line.into());
     }
 
+    pub fn row_width(&self, row: usize) -> usize {
+        let row_widths = (0..self.count_columns())
+            .map(|col| {
+                let style = self.style(&Entity::Cell(row, col));
+                let content = self.get_cell_content(row, col);
+                string_width(content) + style.padding.left.size + style.padding.right.size
+            })
+            .collect::<Vec<_>>();
+
+        let row_styles = (0..self.count_columns())
+            .map(|col| self.style(&Entity::Cell(row, col)).clone())
+            .collect::<Vec<_>>();
+
+        let row_borders = self.get_inner_split_line(row);
+
+        row_width(
+            &row_styles,
+            &row_widths,
+            &row_borders,
+            0,
+            self.count_columns(),
+        )
+    }
+
     fn add_split_lines(&mut self, entity: Entity, border: &Border) {
         match entity {
             Entity::Global => {
diff --git a/src/width.rs b/src/width.rs
index f5d3d82..b2e872c 100644
--- a/src/width.rs
+++ b/src/width.rs
@@ -2,8 +2,11 @@
 //!
 //! - [Truncate] cuts a cell content to limit width.
 //! - [Wrap] split the content via new lines in order to fit max width.
+//! - [TotalWidth] tries to set an whole table width to the limit width.
 
-use crate::CellOption;
+use std::collections::{HashMap, HashSet};
+
+use crate::{CellOption, TableOption};
 use papergrid::{string_width, Entity, Grid, Settings};
 
 /// MaxWidth allows you to set a max width of an object on a [Grid],
@@ -129,11 +132,14 @@ impl Wrap {
 impl CellOption for Wrap {
     fn change_cell(&mut self, grid: &mut Grid, row: usize, column: usize) {
         let content = grid.get_cell_content(row, column);
-        let wrapped_content = if !self.keep_words {
+        let wrapped_content = if self.width == 0 {
+            String::new()
+        } else if !self.keep_words {
             split(content, self.width)
         } else {
             split_keeping_words(content, self.width)
         };
+
         grid.set(
             &Entity::Cell(row, column),
             Settings::new().text(wrapped_content),
@@ -405,3 +411,191 @@ fn increase_width(s: &str, width: usize, fill_with: char) -> String {
             .collect::<String>()
     }
 }
+
+/// TotalWidth decrease or increase an total table width according to the limit.
+///
+/// Beware that borders are not removed when you set a size value to very small.
+/// For example if you set size to 0 the table still be rendered but with all content removed.
+///
+/// Also be aware that it doesn't changes [crate::Indent] settings.
+///
+/// The function is color aware if a `color` feature is on.
+///
+/// ## Example
+///
+/// ```
+/// use tabled::{TotalWidth, Table};
+///
+/// let table = Table::new(&["Hello World!"]).with(TotalWidth::new(5));
+/// ```
+pub struct TotalWidth {
+    size: usize,
+    wrap: bool,
+    wrap_keeping_words: bool,
+}
+
+impl TotalWidth {
+    /// Creates a new instance of TotalWidth.
+    ///
+    /// Default truncate method is [Truncate].
+    pub fn new(size: usize) -> Self {
+        Self {
+            size,
+            wrap: false,
+            wrap_keeping_words: false,
+        }
+    }
+
+    /// Set's a truncate logic to [Wrap].
+    pub fn wrap(mut self, keep_words: bool) -> Self {
+        self.wrap = true;
+        self.wrap_keeping_words = keep_words;
+        self
+    }
+}
+
+impl TableOption for TotalWidth {
+    fn change(&mut self, grid: &mut Grid) {
+        if grid.count_columns() == 0 || grid.count_rows() == 0 {
+            return;
+        }
+
+        let total_width = grid.total_width();
+        if total_width == self.size {
+            return;
+        }
+
+        if self.size > total_width {
+            increase_total_width(grid, self.size);
+        } else {
+            decrease_total_width(grid, self.size, self.wrap, self.wrap_keeping_words);
+        }
+    }
+}
+
+fn increase_total_width(grid: &mut Grid, expected_width: usize) {
+    let mut increase_list = HashMap::new();
+    let total_width = grid.total_width();
+    let mut size = expected_width;
+    for col in (0..grid.count_columns()).cycle() {
+        if size == total_width {
+            break;
+        }
+
+        let mut increased = false;
+        for row in 0..grid.count_rows() {
+            let style = grid.style(&Entity::Cell(row, col));
+            if style.span == 0 {
+                continue;
+            }
+
+            increase_list
+                .entry((row, col))
+                .and_modify(|e| *e += 1)
+                .or_insert(1);
+
+            increased = true;
+        }
+
+        if increased {
+            size -= 1;
+        }
+    }
+
+    for ((row, col), inc) in increase_list {
+        let content = grid.get_cell_content(row, col);
+        let content_width = string_width(content);
+        MinWidth::new(content_width + inc).change_cell(grid, row, col);
+    }
+}
+
+fn decrease_total_width(
+    grid: &mut Grid,
+    expected_width: usize,
+    wrap: bool,
+    wrap_keeping_words: bool,
+) -> bool {
+    let contents = build_contents(grid);
+    let mut widths = build_widths(grid);
+    let mut changes = HashSet::new();
+
+    while expected_width != grid.total_width() {
+        let row =
+            find_biggest_row(grid).expect("must never happen because we checked the length before");
+        let col = find_biggest_cell(&widths[row])
+            .expect("must never happen because we checked the length before");
+        let width = widths[row][col];
+
+        if width == 0 {
+            // we checkend each cell and the biggest is 0
+            // so we can't do anything more in case of decrease
+            return false;
+        }
+
+        Truncate::new(width - 1).change_cell(grid, row, col);
+
+        widths[row][col] -= 1;
+        changes.insert((row, col));
+    }
+
+    if !wrap {
+        return true;
+    }
+
+    set_contents(grid, contents);
+
+    for (row, col) in changes {
+        let width = widths[row][col];
+
+        let mut wrap = Wrap::new(width);
+        if wrap_keeping_words {
+            wrap = wrap.keep_words();
+        }
+
+        wrap.change_cell(grid, row, col)
+    }
+
+    true
+}
+
+fn build_contents(grid: &Grid) -> Vec<Vec<String>> {
+    (0..grid.count_rows())
+        .map(|row| {
+            (0..grid.count_columns())
+                .map(|col| {
+                    let content = grid.get_cell_content(row, col);
+                    content.to_string()
+                })
+                .collect()
+        })
+        .collect()
+}
+
+fn set_contents(grid: &mut Grid, contents: Vec<Vec<String>>) {
+    contents.into_iter().enumerate().for_each(|(row, rows)| {
+        rows.into_iter().enumerate().for_each(|(col, content)| {
+            grid.set(&Entity::Cell(row, col), Settings::default().text(content));
+        })
+    })
+}
+
+fn build_widths(grid: &Grid) -> Vec<Vec<usize>> {
+    (0..grid.count_rows())
+        .map(|row| {
+            (0..grid.count_columns())
+                .map(|col| {
+                    let content = grid.get_cell_content(row, col);
+                    string_width(content)
+                })
+                .collect()
+        })
+        .collect()
+}
+
+fn find_biggest_row(grid: &Grid) -> Option<usize> {
+    (0..grid.count_rows()).max_by_key(|&row| grid.row_width(row))
+}
+
+fn find_biggest_cell(widths: &[usize]) -> Option<usize> {
+    (0..widths.len()).max_by_key(|&col| widths[col])
+}


Test patch:
diff --git a/tests/width_test.rs b/tests/width_test.rs
index 5879323..69adced 100644
--- a/tests/width_test.rs
+++ b/tests/width_test.rs
@@ -1,7 +1,7 @@
 use crate::util::create_vector;
 use tabled::{
     object::{Cell, Columns, Full, Object, Rows},
-    Alignment, MaxWidth, MinWidth, Modify, Style, Table,
+    Alignment, MaxWidth, MinWidth, Modify, Panel, Style, Table, TotalWidth,
 };
 
 mod util;
@@ -569,3 +569,153 @@ fn min_width_color_with_smaller_then_width() {
         Table::new(data).to_string()
     );
 }
+
+#[test]
+fn total_width_big() {
+    let data = create_vector::<3, 3>();
+    let table = Table::new(&data)
+        .with(Style::github_markdown())
+        .with(TotalWidth::new(80))
+        .to_string();
+
+    let expected = concat!(
+        "|      N       |      column 0       |      column 1      |      column 2      |\n",
+        "|--------------+---------------------+--------------------+--------------------|\n",
+        "|      0       |         0-0         |        0-1         |        0-2         |\n",
+        "|      1       |         1-0         |        1-1         |        1-2         |\n",
+        "|      2       |         2-0         |        2-1         |        2-2         |\n",
+    );
+
+    assert_eq!(table, expected);
+}
+
+#[test]
+fn total_width_big_with_panel() {
+    let data = create_vector::<3, 3>();
+    let table = Table::new(&data)
+        .with(Panel("Hello World", 0))
+        .with(Modify::new(Full).with(Alignment::center_horizontal()))
+        .with(Style::github_markdown())
+        .with(TotalWidth::new(80))
+        .to_string();
+
+    let expected = concat!(
+        "|                                 Hello World                                  |\n",
+        "|--------------+---------------------+--------------------+--------------------|\n",
+        "|      N       |      column 0       |      column 1      |      column 2      |\n",
+        "|      0       |         0-0         |        0-1         |        0-2         |\n",
+        "|      1       |         1-0         |        1-1         |        1-2         |\n",
+        "|      2       |         2-0         |        2-1         |        2-2         |\n",
+    );
+
+    assert_eq!(table, expected);
+}
+
+#[test]
+fn total_width_big_with_panel_with_wrapping_doesnt_affect_increase() {
+    let data = create_vector::<3, 3>();
+    let table1 = Table::new(&data)
+        .with(Panel("Hello World", 0))
+        .with(Modify::new(Full).with(Alignment::center_horizontal()))
+        .with(Style::github_markdown())
+        .with(TotalWidth::new(80).wrap(false))
+        .to_string();
+
+    let table2 = Table::new(&data)
+        .with(Panel("Hello World", 0))
+        .with(Modify::new(Full).with(Alignment::center_horizontal()))
+        .with(Style::github_markdown())
+        .with(TotalWidth::new(80))
+        .to_string();
+
+    assert_eq!(table1, table2);
+}
+
+#[test]
+fn total_width_small() {
+    let data = create_vector::<3, 3>();
+    let table = Table::new(&data)
+        .with(Style::github_markdown())
+        .with(TotalWidth::new(14))
+        .to_string();
+
+    let expected = concat!(
+        "| N |  |  |  |\n",
+        "|---+--+--+--|\n",
+        "| 0 |  |  |  |\n",
+        "| 1 |  |  |  |\n",
+        "| 2 |  |  |  |\n",
+    );
+
+    assert_eq!(table, expected);
+}
+
+#[test]
+fn total_width_smaller_then_content() {
+    let data = create_vector::<3, 3>();
+    let table = Table::new(&data)
+        .with(Style::github_markdown())
+        .with(TotalWidth::new(8))
+        .to_string();
+
+    let expected = concat!(
+        "|  |  |  |  |\n",
+        "|--+--+--+--|\n",
+        "|  |  |  |  |\n",
+        "|  |  |  |  |\n",
+        "|  |  |  |  |\n",
+    );
+
+    assert_eq!(table, expected);
+}
+
+#[test]
+fn total_width_small_with_panel() {
+    let data = create_vector::<3, 3>();
+    let table = Table::new(&data)
+        .with(Panel("Hello World", 0))
+        .with(Modify::new(Full).with(Alignment::center_horizontal()))
+        .with(Style::github_markdown())
+        // .with(Modify::new(Full).with(Indent::new(0, 0, 0, 0)))
+        .with(TotalWidth::new(20))
+        .to_string();
+
+    let expected = concat!(
+        "|   Hello World    |\n",
+        "|---+----+----+----|\n",
+        "| N | co | co | co |\n",
+        "| 0 | 0- | 0- | 0- |\n",
+        "| 1 | 1- | 1- | 1- |\n",
+        "| 2 | 2- | 2- | 2- |\n",
+    );
+
+    assert_eq!(table, expected);
+}
+
+#[test]
+fn total_width_small_with_panel_using_wrapping() {
+    let data = create_vector::<3, 3>();
+    let table = Table::new(&data)
+        .with(Panel("Hello World", 0))
+        .with(Modify::new(Full).with(Alignment::center_horizontal()))
+        .with(Style::github_markdown())
+        .with(TotalWidth::new(20).wrap(false))
+        .to_string();
+
+    let expected = concat!(
+        "|   Hello World    |\n",
+        "|---+----+----+----|\n",
+        "| N | co | co | co |\n",
+        "|   | lu | lu | lu |\n",
+        "|   | mn | mn | mn |\n",
+        "|   | 0  | 1  | 2  |\n",
+        "| 0 | 0- | 0- | 0- |\n",
+        "|   | 0  | 1  | 2  |\n",
+        "| 1 | 1- | 1- | 1- |\n",
+        "|   | 0  | 1  | 2  |\n",
+        "| 2 | 2- | 2- | 2- |\n",
+        "|   | 0  | 1  | 2  |\n",
+    );
+
+    assert_eq!(table, expected);
+}