Repository: zhiburt/tabled (rust)

Issue:
Add an option to avoid line-breaking in the middle of a word
Currently I'm just putting some extra space in my strings so that the whole word gets wrapped to next line instead of line-wrapping in the middle of a word.

It would be cool to have an option to automatically line-wrap at word boundaries.

Pull request description:
Add MaxWidth option to not break words
closes #57

Patch:
diff --git a/examples/width.rs b/examples/width.rs
index c39a347..78e44fe 100644
--- a/examples/width.rs
+++ b/examples/width.rs
@@ -12,7 +12,7 @@ fn main() {
 
     let table = Table::new(&data).with(Style::github_markdown()).with(
         Modify::new(Full)
-            .with(MaxWidth::truncating(10, "..."))
+            .with(MaxWidth::truncating(10).suffix("..."))
             .with(Alignment::left()),
     );
 
diff --git a/src/width.rs b/src/width.rs
index 28ad8a2..bf21fbd 100644
--- a/src/width.rs
+++ b/src/width.rs
@@ -16,65 +16,91 @@ use papergrid::{Entity, Grid, Settings};
 ///
 /// let table = Table::new(&data)
 ///     .with(Style::github_markdown())
-///     .with(Modify::new(Full).with(MaxWidth::truncating(5, "...")));
+///     .with(Modify::new(Full).with(MaxWidth::truncating(5).suffix("...")));
 /// ```
 ///
 /// While working with colors you must setup `colors` feature.
-pub struct MaxWidth<S> {
+pub struct MaxWidth;
+
+impl MaxWidth {
+    pub fn truncating(width: usize) -> Truncate<&'static str> {
+        Truncate::new(width)
+    }
+
+    pub fn wrapping(width: usize) -> Wrap {
+        Wrap::new(width)
+    }
+}
+
+pub struct Truncate<S> {
     width: usize,
-    wrap: Wrap<S>,
+    suffix: S,
+}
+
+impl Truncate<&'static str> {
+    pub fn new(width: usize) -> Self {
+        Self { width, suffix: "" }
+    }
 }
 
-enum Wrap<S> {
-    Truncate(S),
-    Wrap,
+impl<T> Truncate<T> {
+    pub fn suffix<S>(self, suffix: S) -> Truncate<S> {
+        Truncate {
+            width: self.width,
+            suffix,
+        }
+    }
 }
 
-impl<S> MaxWidth<S>
+impl<S> CellOption for Truncate<S>
 where
     S: AsRef<str>,
 {
-    pub fn truncating(width: usize, suffix: S) -> Self {
-        Self {
-            width,
-            wrap: Wrap::Truncate(suffix),
+    fn change_cell(&mut self, grid: &mut Grid, row: usize, column: usize) {
+        let content = grid.get_cell_content(row, column);
+        let striped_content = strip(content, self.width);
+        if striped_content.len() < content.len() {
+            let new_content = format!("{}{}", striped_content, self.suffix.as_ref());
+            grid.set(
+                &Entity::Cell(row, column),
+                Settings::new().text(new_content),
+            )
         }
     }
 }
 
-impl MaxWidth<&'static str> {
-    pub fn wrapping(width: usize) -> Self {
+pub struct Wrap {
+    width: usize,
+    keep_words: bool,
+}
+
+impl Wrap {
+    pub fn new(width: usize) -> Self {
         Self {
             width,
-            wrap: Wrap::Wrap,
+            keep_words: false,
         }
     }
+
+    /// Set the wrap's keep words.
+    pub fn keep_words(mut self) -> Self {
+        self.keep_words = true;
+        self
+    }
 }
 
-impl<S: AsRef<str>> CellOption for MaxWidth<S> {
+impl CellOption for Wrap {
     fn change_cell(&mut self, grid: &mut Grid, row: usize, column: usize) {
         let content = grid.get_cell_content(row, column);
-        match &self.wrap {
-            Wrap::Truncate(filler) => {
-                let striped_content = strip(content, self.width);
-                if striped_content.len() < content.len() {
-                    let new_content = format!("{}{}", striped_content, filler.as_ref());
-                    grid.set(
-                        &Entity::Cell(row, column),
-                        Settings::new().text(new_content),
-                    )
-                }
-            }
-            Wrap::Wrap => {
-                let wrapped_content = split(content, self.width);
-                if wrapped_content.len() != content.len() {
-                    grid.set(
-                        &Entity::Cell(row, column),
-                        Settings::new().text(wrapped_content),
-                    )
-                }
-            }
-        }
+        let wrapped_content = 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),
+        )
     }
 }
 
@@ -116,6 +142,126 @@ pub(crate) fn split(s: &str, width: usize) -> String {
     }
 }
 
+#[cfg(not(feature = "color"))]
+fn split_keeping_words(s: &str, width: usize) -> String {
+    let mut buf = String::new();
+    let mut i = 0;
+    for c in s.chars() {
+        if i != 0 && i % width == 0 {
+            let prev_c = buf.chars().last().unwrap();
+            let is_splitting_word = !prev_c.is_whitespace() && !c.is_whitespace();
+            if is_splitting_word {
+                let pos = buf.chars().rev().position(|c| c.is_whitespace());
+                match pos {
+                    Some(pos) => {
+                        if pos < width {
+                            // it's a part of a word which we is ok to move to the next line;
+                            // we know that there will be enough space for this part + next character.
+                            //
+                            // todo: test about this next char space
+                            let range_len = buf
+                                .chars()
+                                .rev()
+                                .take(pos)
+                                .map(|c| c.len_utf8())
+                                .sum::<usize>();
+                            buf.insert(buf.len() - range_len, '\n');
+                            i = range_len;
+                        } else {
+                            // The words is too long to be moved,
+                            // we can't move it any way so just leave everything as it is
+                            buf.push('\n');
+                        }
+                    }
+                    None => {
+                        // We don't find a whitespace
+                        // so its a long word so we can do nothing about it
+                        buf.push('\n');
+                    }
+                }
+            } else {
+                // This place doesn't separate a word
+                // So we just do a general split.
+                buf.push('\n');
+            }
+        }
+
+        buf.push(c);
+
+        i += 1;
+    }
+
+    buf
+}
+
+#[cfg(feature = "color")]
+fn split_keeping_words(s: &str, width: usize) -> String {
+    use ansi_str::AnsiStr;
+
+    let mut buf = String::new();
+    let mut s = s.to_string();
+    while !s.is_empty() {
+        let width = to_byte_length(&s, width);
+        let (mut lhs, mut rhs) = s.ansi_split_at(width);
+
+        let lhs_stripped = lhs.ansi_strip();
+        let left_ends_with_letter = lhs_stripped
+            .chars()
+            .last()
+            .map_or(false, |c| !c.is_whitespace());
+        let right_starts_with_letter = rhs
+            .ansi_strip()
+            .chars()
+            .next()
+            .map_or(false, |c| !c.is_whitespace());
+        let is_splitting_word = left_ends_with_letter && right_starts_with_letter;
+        if is_splitting_word {
+            let pos = lhs_stripped.chars().rev().position(|c| c.is_whitespace());
+            match pos {
+                Some(pos) => {
+                    if pos < width {
+                        // it's a part of a word which we is ok to move to the next line;
+                        // we know that there will be enough space for this part + next character.
+                        //
+                        // todo: test about this next char space
+                        let range_len = lhs_stripped
+                            .chars()
+                            .rev()
+                            .take(pos)
+                            .map(|c| c.len_utf8())
+                            .sum::<usize>();
+
+                        let move_part = lhs.ansi_get(lhs_stripped.len() - range_len..).unwrap();
+                        lhs = lhs.ansi_get(..lhs_stripped.len() - range_len).unwrap();
+                        rhs = move_part + &rhs;
+
+                        buf.push_str(&lhs);
+                        buf.push('\n');
+                    } else {
+                        // The words is too long to be moved,
+                        // we can't move it any way so just leave everything as it is
+                        buf.push_str(&lhs);
+                        buf.push('\n');
+                    }
+                }
+                None => {
+                    // We don't find a whitespace
+                    // so its a long word so we can do nothing about it
+                    buf.push_str(&lhs);
+                    buf.push('\n');
+                }
+            }
+        } else {
+            buf.push_str(&lhs);
+            buf.push('\n');
+        }
+
+        s = rhs;
+    }
+
+    buf
+}
+
 #[cfg(feature = "color")]
 fn to_byte_length(s: &str, width: usize) -> usize {
     s.chars().take(width).map(|c| c.len_utf8()).sum::<usize>()


Test patch:
diff --git a/tests/width_test.rs b/tests/width_test.rs
index 0c3280c..53edc30 100644
--- a/tests/width_test.rs
+++ b/tests/width_test.rs
@@ -1,5 +1,5 @@
 use crate::util::create_vector;
-use tabled::{Column, Full, MaxWidth, Modify, Object, Row, Style, Table};
+use tabled::{Alignment, Column, Full, MaxWidth, Modify, Object, Row, Style, Table};
 
 mod util;
 
@@ -8,7 +8,26 @@ fn max_width() {
     let data = create_vector::<3, 3>();
     let table = Table::new(&data)
         .with(Style::github_markdown())
-        .with(Modify::new(Column(1..).not(Row(..1))).with(MaxWidth::truncating(2, "...")))
+        .with(Modify::new(Column(1..).not(Row(..1))).with(MaxWidth::truncating(1)))
+        .to_string();
+
+    let expected = concat!(
+        "| N | column 0 | column 1 | column 2 |\n",
+        "|---+----------+----------+----------|\n",
+        "| 0 |    0     |    0     |    0     |\n",
+        "| 1 |    1     |    1     |    1     |\n",
+        "| 2 |    2     |    2     |    2     |\n",
+    );
+
+    assert_eq!(table, expected);
+}
+
+#[test]
+fn max_width_with_suffix() {
+    let data = create_vector::<3, 3>();
+    let table = Table::new(&data)
+        .with(Style::github_markdown())
+        .with(Modify::new(Column(1..).not(Row(..1))).with(MaxWidth::truncating(2).suffix("...")))
         .to_string();
 
     let expected = concat!(
@@ -22,6 +41,25 @@ fn max_width() {
     assert_eq!(table, expected);
 }
 
+#[test]
+fn max_width_doesnt_icrease_width_if_it_is_smaller() {
+    let data = create_vector::<3, 3>();
+    let table = Table::new(&data)
+        .with(Style::github_markdown())
+        .with(Modify::new(Column(1..).not(Row(..1))).with(MaxWidth::truncating(50)))
+        .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 max_width_wrapped() {
     let data = create_vector::<3, 3>();
@@ -44,6 +82,235 @@ fn max_width_wrapped() {
     assert_eq!(table, expected);
 }
 
+#[test]
+fn max_width_wrapped_does_nothing_if_str_is_smaller() {
+    let data = create_vector::<3, 3>();
+    let table = Table::new(&data)
+        .with(Style::github_markdown())
+        .with(Modify::new(Column(1..).not(Row(..1))).with(MaxWidth::wrapping(100)))
+        .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 max_width_wrapped_keep_words() {
+    let data = vec!["this is a long sentence"];
+    let table = Table::new(&data)
+        .with(Style::github_markdown())
+        .with(Modify::new(Full).with(Alignment::left()))
+        .with(Modify::new(Full).with(MaxWidth::wrapping(17).keep_words()))
+        .to_string();
+
+    let expected = concat!(
+        "| &str            |\n",
+        "|-----------------|\n",
+        "| this is a long  |\n",
+        "| sentence        |\n",
+    );
+
+    assert_eq!(table, expected);
+
+    let data = vec!["this is a long  sentence"];
+    let table = Table::new(&data)
+        .with(Style::github_markdown())
+        .with(Modify::new(Full).with(Alignment::left()))
+        .with(Modify::new(Full).with(MaxWidth::wrapping(17).keep_words()))
+        .to_string();
+
+    let expected = concat!(
+        "| &str             |\n",
+        "|------------------|\n",
+        "| this is a long   |\n",
+        "| sentence         |\n",
+    );
+
+    assert_eq!(table, expected);
+
+    let data = vec!["this is a long   sentence"];
+    let table = Table::new(&data)
+        .with(Style::github_markdown())
+        .with(Modify::new(Full).with(Alignment::left()))
+        .with(Modify::new(Full).with(MaxWidth::wrapping(17).keep_words()))
+        .to_string();
+
+    let expected = concat!(
+        "| &str              |\n",
+        "|-------------------|\n",
+        "| this is a long    |\n",
+        "| sentence          |\n",
+    );
+
+    assert_eq!(table, expected);
+
+    let data = vec!["this is a long    sentence"];
+    let table = Table::new(&data)
+        .with(Style::github_markdown())
+        .with(Modify::new(Full).with(Alignment::left()))
+        .with(Modify::new(Full).with(MaxWidth::wrapping(17).keep_words()))
+        .to_string();
+
+    // 'sentence' doesnt have a space ' sentence' because we use left alignment
+    let expected = concat!(
+        "| &str              |\n",
+        "|-------------------|\n",
+        "| this is a long    |\n",
+        "| sentence          |\n",
+    );
+
+    assert_eq!(table, expected);
+
+    let data = vec!["this"];
+    let table = Table::new(&data)
+        .with(Style::github_markdown())
+        .with(Modify::new(Full).with(MaxWidth::wrapping(10).keep_words()))
+        .to_string();
+
+    let expected = concat!("| &str |\n", "|------|\n", "| this |\n",);
+
+    assert_eq!(table, expected);
+}
+
+#[cfg(feature = "color")]
+#[test]
+fn max_width_wrapped_keep_words_color() {
+    use owo_colors::OwoColorize;
+
+    let data = vec!["this is a long sentence".on_black().green().to_string()];
+    let table = Table::new(&data)
+        .with(Style::GITHUB_MARKDOWN)
+        .with(Modify::new(Full).with(Alignment::left()))
+        .with(Modify::new(Full).with(MaxWidth::wrapping(17).keep_words()))
+        .to_string();
+
+    let expected = concat!(
+        "| String          |\n",
+        "|-----------------|\n",
+        "| \u{1b}[32m\u{1b}[40mthis is a long \u{1b}[39m\u{1b}[49m |\n",
+        "| \u{1b}[32m\u{1b}[40mse\u{1b}[39m\u{1b}[49m\u{1b}[32m\u{1b}[40mntence\u{1b}[39m\u{1b}[49m        |\n",
+    );
+
+    assert_eq!(table, expected);
+
+    let data = vec!["this is a long  sentence".on_black().green().to_string()];
+    let table = Table::new(&data)
+        .with(Style::GITHUB_MARKDOWN)
+        .with(Modify::new(Full).with(Alignment::left()))
+        .with(Modify::new(Full).with(MaxWidth::wrapping(17).keep_words()))
+        .to_string();
+
+    let expected = concat!(
+        "| String           |\n",
+        "|------------------|\n",
+        "| \u{1b}[32m\u{1b}[40mthis is a long  \u{1b}[39m\u{1b}[49m |\n",
+        "| \u{1b}[32m\u{1b}[40ms\u{1b}[39m\u{1b}[49m\u{1b}[32m\u{1b}[40mentence\u{1b}[39m\u{1b}[49m         |\n",
+    );
+
+    println!("{}", table);
+
+    assert_eq!(table, expected);
+
+    let data = vec!["this is a long   sentence".on_black().green().to_string()];
+    let table = Table::new(&data)
+        .with(Style::GITHUB_MARKDOWN)
+        .with(Modify::new(Full).with(Alignment::left()))
+        .with(Modify::new(Full).with(MaxWidth::wrapping(17).keep_words()))
+        .to_string();
+
+    let expected = concat!(
+        "| String            |\n",
+        "|-------------------|\n",
+        "| \u{1b}[32m\u{1b}[40mthis is a long   \u{1b}[39m\u{1b}[49m |\n",
+        "| \u{1b}[32m\u{1b}[40msentence\u{1b}[39m\u{1b}[49m          |\n",
+    );
+
+    assert_eq!(table, expected);
+
+    let data = vec!["this is a long    sentence".on_black().green().to_string()];
+    let table = Table::new(&data)
+        .with(Style::GITHUB_MARKDOWN)
+        .with(Modify::new(Full).with(Alignment::left()))
+        .with(Modify::new(Full).with(MaxWidth::wrapping(17).keep_words()))
+        .to_string();
+
+    let expected = concat!(
+        "| String            |\n",
+        "|-------------------|\n",
+        "| \u{1b}[32m\u{1b}[40mthis is a long   \u{1b}[39m\u{1b}[49m |\n",
+        "| \u{1b}[32m\u{1b}[40m sentence\u{1b}[39m\u{1b}[49m         |\n",
+    );
+
+    assert_eq!(table, expected);
+
+    let data = vec!["this".on_black().green().to_string()];
+    let table = Table::new(&data)
+        .with(Style::GITHUB_MARKDOWN)
+        .with(Modify::new(Full).with(MaxWidth::wrapping(10).keep_words()))
+        .to_string();
+
+    let expected = concat!(
+        "| String |\n",
+        "|--------|\n",
+        "|  \u{1b}[32m\u{1b}[40mthis\u{1b}[39m\u{1b}[49m  |\n",
+    );
+
+    assert_eq!(table, expected);
+}
+
+#[test]
+fn max_width_wrapped_keep_words_long_word() {
+    let data = vec!["this is a long sentencesentencesentence"];
+    let table = Table::new(&data)
+        .with(Style::github_markdown())
+        .with(Modify::new(Full).with(Alignment::left()))
+        .with(Modify::new(Full).with(MaxWidth::wrapping(17).keep_words()))
+        .to_string();
+
+    let expected = concat!(
+        "| &str              |\n",
+        "|-------------------|\n",
+        "| this is a long    |\n",
+        "| sentencesentences |\n",
+        "| entence           |\n",
+    );
+
+    assert_eq!(table, expected);
+}
+
+#[cfg(feature = "color")]
+#[test]
+fn max_width_wrapped_keep_words_long_word_color() {
+    use owo_colors::OwoColorize;
+
+    let data = vec!["this is a long sentencesentencesentence"
+        .on_black()
+        .green()
+        .to_string()];
+    let table = Table::new(&data)
+        .with(Style::GITHUB_MARKDOWN)
+        .with(Modify::new(Full).with(Alignment::left()))
+        .with(Modify::new(Full).with(MaxWidth::wrapping(17).keep_words()))
+        .to_string();
+
+    let expected = concat!(
+        "| String            |\n",
+        "|-------------------|\n",
+        "| \u{1b}[32m\u{1b}[40mthis is a long \u{1b}[39m\u{1b}[49m   |\n",
+        "| \u{1b}[32m\u{1b}[40mse\u{1b}[39m\u{1b}[49m\u{1b}[32m\u{1b}[40mntencesentences\u{1b}[39m\u{1b}[49m |\n",
+        "| \u{1b}[32m\u{1b}[40mentence\u{1b}[39m\u{1b}[49m           |\n",
+    );
+
+    assert_eq!(table, expected);
+}
+
 #[cfg(feature = "color")]
 #[test]
 fn max_width_wrapped_collored() {
@@ -84,7 +351,7 @@ fn dont_change_content_if_width_is_less_then_max_width() {
     let data = create_vector::<3, 3>();
     let table = Table::new(&data)
         .with(Style::github_markdown())
-        .with(Modify::new(Full).with(MaxWidth::truncating(1000, "...")))
+        .with(Modify::new(Full).with(MaxWidth::truncating(1000).suffix("...")))
         .to_string();
 
     let expected = concat!(
@@ -112,7 +379,7 @@ fn max_width_with_emoji() {
 
     let table = Table::new(data)
         .with(Style::github_markdown())
-        .with(Modify::new(Full).with(MaxWidth::truncating(3, "...")))
+        .with(Modify::new(Full).with(MaxWidth::truncating(3).suffix("...")))
         .to_string();
 
     assert_eq!(table, _expected);
@@ -138,8 +405,8 @@ fn color_chars_are_stripped() {
     );
 
     let table = Table::new(data)
-        .with(Style::github_markdown())
-        .with(Modify::new(Full).with(MaxWidth::truncating(3, "...")))
+        .with(Style::GITHUB_MARKDOWN)
+        .with(Modify::new(Full).with(MaxWidth::truncating(3).suffix("...")))
         .to_string();
 
     println!("{}", table);