Repository: zhiburt/tabled (rust)

Issue:
Inconsistent truncation/wrapping behavior for multiline text cells
## Issue

Cell truncation and wrapping behaves inconsistently/unintuitively in the case of cells with newlines. See the example below or as a [gist](https://gist.github.com/kalzoo/8b6c7f557b1715d60dc97dcbffc91986):

```rust
fn main() {
    let mut builder = tabled::builder::Builder::default();

    let cell_data = (0..10).map(|v| format!("#{v:02}")).collect::<Vec<_>>().join("\n");
    builder.add_record(vec![cell_data.clone(), cell_data.clone()]);

    let common_table = builder.build();

    let mut truncate_table = common_table.clone();
    truncate_table.with(tabled::Width::truncate(12));
    println!("Narrow data, truncation:");
    println!("{truncate_table}");

    let mut wrap_table = common_table.clone();
    wrap_table.with(tabled::Width::wrap(12));
    println!("Narrow data, wrapping:");
    println!("{wrap_table}");

    // Wider data, same table, behavior changes
    let mut builder = tabled::builder::Builder::default();
    let cell_data = (0..10).map(|v| format!("#{v:05}")).collect::<Vec<_>>().join("\n");
    builder.add_record(vec![cell_data.clone(), cell_data.clone()]);
    
    let common_table = builder.build();

    let mut truncate_table = common_table.clone();
    truncate_table.with(tabled::Width::truncate(12));
    println!("Wide data, truncation:");
    println!("{truncate_table}");

    let mut wrap_table = common_table.clone();
    wrap_table.with(tabled::Width::wrap(12));
    println!("Wide data, wrapping:");
    println!("{wrap_table}");
}
```

## Truncation

The data in the first two examples are "narrow" in that they are all 3 characters wide. The gist has two more examples with 5-character-wide data.

Note: the data is the same in both cells below. When it fits without truncation, all lines are displayed; but if the first line is truncated, the lines below it are removed in their entirety. I might expect `#0`, `#0`, `#0` in that first column.

```
Narrow data, truncation:
+----+-----+
| #0 | #00 |
|    | #01 |
|    | #02 |
|    | #03 |
|    | #04 |
|    | #05 |
|    | #06 |
|    | #07 |
|    | #08 |
|    | #09 |
+----+-----+
```

## Wrapping

The only difference from the table above is the use of wrapping. Note that the newline characters themselves appear to be counted, when they should instead _reset_ the count of characters allowed before the next wrap. So, even though the input data has no empty lines, the table cell does (such as between #03 and #04), and some lines wrap early (like `#\n01`):

```
+----+-----+
| #0 | #00 |
| 0  | #01 |
| #  | #02 |
| 01 | #03 |
|    | #04 |
| #0 | #05 |
| 2  | #06 |
| #  | #07 |
| 03 | #08 |
|    | #09 |
| #0 |     |
| 4  |     |
| #  |     |
| 05 |     |
|    |     |
| #0 |     |
| 6  |     |
| #  |     |
| 07 |     |
|    |     |
| #0 |     |
| 8  |     |
| #  |     |
| 09 |     |
+----+-----+
```

# Possible fix

Without yet investigating the code, it appears that cell contents are treated as a single line for truncation & wrapping logic; however, each individual line within a cell should be wrapped or truncated independently. 

The user can work around this by registering each line of a cell as its own independent cell, which will be clumsy to do (because each cell would need special formatting rules to decide which borders to render). If that's the intended behavior though, a note in the readme about multiline text cell behavior might help others as well.

Thanks!



Pull request description:
tabled/ Add option to truncate as multiline
fix #308

Patch:
diff --git a/tabled/src/settings/width/truncate.rs b/tabled/src/settings/width/truncate.rs
index 0b17954..23dd152 100644
--- a/tabled/src/settings/width/truncate.rs
+++ b/tabled/src/settings/width/truncate.rs
@@ -2,7 +2,7 @@
 //!
 //! [`Table`]: crate::Table
 
-use std::{borrow::Cow, iter, marker::PhantomData, ops::Deref};
+use std::{borrow::Cow, iter, marker::PhantomData};
 
 use crate::{
     grid::{
@@ -42,6 +42,7 @@ use super::util::{cut_str, get_table_widths, get_table_widths_with_total};
 pub struct Truncate<'a, W = usize, P = PriorityNone> {
     width: W,
     suffix: Option<TruncateSuffix<'a>>,
+    multiline: bool,
     _priority: PhantomData<P>,
 }
 
@@ -83,6 +84,7 @@ where
     pub fn new(width: W) -> Truncate<'static, W> {
         Self {
             width,
+            multiline: false,
             suffix: None,
             _priority: PhantomData::default(),
         }
@@ -104,6 +106,7 @@ impl<'a, W, P> Truncate<'a, W, P> {
 
         Truncate {
             width: self.width,
+            multiline: self.multiline,
             suffix: Some(suff),
             _priority: PhantomData::default(),
         }
@@ -116,11 +119,22 @@ impl<'a, W, P> Truncate<'a, W, P> {
 
         Truncate {
             width: self.width,
+            multiline: self.multiline,
             suffix: Some(suff),
             _priority: PhantomData::default(),
         }
     }
 
+    /// Use trancate logic per line, not as a string as a whole.
+    pub fn multiline(self) -> Truncate<'a, W, P> {
+        Truncate {
+            width: self.width,
+            multiline: true,
+            suffix: self.suffix,
+            _priority: self._priority,
+        }
+    }
+
     #[cfg(feature = "color")]
     /// Sets a optional logic to try to colorize a suffix.
     pub fn suffix_try_color(self, color: bool) -> Truncate<'a, W, P> {
@@ -129,6 +143,7 @@ impl<'a, W, P> Truncate<'a, W, P> {
 
         Truncate {
             width: self.width,
+            multiline: self.multiline,
             suffix: Some(suff),
             _priority: PhantomData::default(),
         }
@@ -147,6 +162,7 @@ impl<'a, W, P> Truncate<'a, W, P> {
     pub fn priority<PP: Peaker>(self) -> Truncate<'a, W, PP> {
         Truncate {
             width: self.width,
+            multiline: self.multiline,
             suffix: self.suffix,
             _priority: PhantomData::default(),
         }
@@ -172,45 +188,81 @@ where
         cfg: &mut ColoredConfig,
         entity: papergrid::config::Entity,
     ) {
-        let truncate_width = self.width.measure(&*records, cfg);
+        let available = self.width.measure(&*records, cfg);
 
-        let mut width = truncate_width;
+        let mut width = available;
         let mut suffix = Cow::Borrowed("");
 
         if let Some(x) = self.suffix.as_ref() {
-            let (s, w) = make_suffix(x, width);
-            suffix = s;
-            width = w;
+            let (cutted_suffix, rest_width) = make_suffix(x, width);
+            suffix = cutted_suffix;
+            width = rest_width;
         };
 
         let count_rows = records.count_rows();
         let count_columns = records.count_columns();
 
-        let save_suffix_color = need_suffix_color_preservation(&self.suffix);
+        let colorize = need_suffix_color_preservation(&self.suffix);
 
         for pos in entity.iter(count_rows, count_columns) {
             let text = records.get_text(pos);
 
             let cell_width = string_width_multiline(text);
-            if truncate_width >= cell_width {
+            if available >= cell_width {
                 continue;
             }
 
-            let text = if width == 0 {
-                if truncate_width == 0 {
-                    Cow::Borrowed("")
-                } else {
-                    Cow::Borrowed(suffix.deref())
-                }
-            } else {
-                truncate_text(text, width, &suffix, save_suffix_color)
-            };
+            let text =
+                truncate_multiline(text, &suffix, width, available, colorize, self.multiline);
 
             records.set(pos, text.into_owned());
         }
     }
 }
 
+fn truncate_multiline<'a>(
+    text: &'a str,
+    suffix: &'a str,
+    width: usize,
+    twidth: usize,
+    suffix_color: bool,
+    multiline: bool,
+) -> Cow<'a, str> {
+    if multiline {
+        let mut buf = String::new();
+        for (i, line) in crate::grid::util::string::get_lines(text).enumerate() {
+            if i != 0 {
+                buf.push('\n');
+            }
+
+            let line = make_text_truncated(&line, suffix, width, twidth, suffix_color);
+            buf.push_str(&line);
+        }
+
+        Cow::Owned(buf)
+    } else {
+        make_text_truncated(text, suffix, width, twidth, suffix_color)
+    }
+}
+
+fn make_text_truncated<'a>(
+    text: &'a str,
+    suffix: &'a str,
+    width: usize,
+    twidth: usize,
+    suffix_color: bool,
+) -> Cow<'a, str> {
+    if width == 0 {
+        if twidth == 0 {
+            Cow::Borrowed("")
+        } else {
+            Cow::Borrowed(suffix)
+        }
+    } else {
+        truncate_text(text, width, suffix, suffix_color)
+    }
+}
+
 fn need_suffix_color_preservation(_suffix: &Option<TruncateSuffix<'_>>) -> bool {
     #[cfg(not(feature = "color"))]
     {
@@ -271,16 +323,18 @@ where
             try_color: s.try_color,
         });
 
-        let widths = truncate_total_width(records, cfg, widths, total, width, P::create(), suffix);
+        let priority = P::create();
+        let multiline = self.multiline;
+        let widths = truncate_total_width(
+            records, cfg, widths, total, width, priority, suffix, multiline,
+        );
 
         let _ = dims.set_widths(widths);
     }
 }
 
-fn truncate_total_width<
-    P: Peaker,
-    R: Records + PeekableRecords + ExactRecords + RecordsMut<String>,
->(
+#[allow(clippy::too_many_arguments)]
+fn truncate_total_width<P, R>(
     records: &mut R,
     cfg: &mut ColoredConfig,
     mut widths: Vec<usize>,
@@ -288,9 +342,12 @@ fn truncate_total_width<
     width: usize,
     priority: P,
     suffix: Option<TruncateSuffix<'_>>,
+    multiline: bool,
 ) -> Vec<usize>
 where
     for<'a> &'a R: Records,
+    P: Peaker,
+    R: Records + PeekableRecords + ExactRecords + RecordsMut<String>,
 {
     let count_rows = records.count_rows();
     let count_columns = records.count_columns();
@@ -303,6 +360,7 @@ where
 
     let mut truncate = Truncate::new(0);
     truncate.suffix = suffix;
+    truncate.multiline = multiline;
     for ((row, col), width) in points {
         truncate.width = width;
         CellOption::change(&mut truncate, records, cfg, (row, col).into());
@@ -312,21 +370,20 @@ where
 }
 
 fn truncate_text<'a>(
-    content: &'a str,
+    text: &'a str,
     width: usize,
     suffix: &str,
-    _suffix_color_try_keeping: bool,
+    _suffix_color: bool,
 ) -> Cow<'a, str> {
-    let content = cut_str(content, width);
-
+    let content = cut_str(text, width);
     if suffix.is_empty() {
         return content;
     }
 
     #[cfg(feature = "color")]
     {
-        if _suffix_color_try_keeping {
-            if let Some(block) = ansi_str::get_blocks(&content).last() {
+        if _suffix_color {
+            if let Some(block) = ansi_str::get_blocks(text).last() {
                 if block.has_ansi() {
                     let style = block.style();
                     Cow::Owned(format!(
diff --git a/tabled/src/settings/width/util.rs b/tabled/src/settings/width/util.rs
index daf7979..9548351 100644
--- a/tabled/src/settings/width/util.rs
+++ b/tabled/src/settings/width/util.rs
@@ -45,6 +45,7 @@ pub(crate) fn cut_str(s: &str, width: usize) -> Cow<'_, str> {
 
         buf
     }
+
     #[cfg(not(feature = "color"))]
     {
         cut_str_basic(s, width)


Test patch:
diff --git a/json_to_table/tests/test_config.rs b/json_to_table/tests/test_config.rs
index a144882..303d5b0 100644
--- a/json_to_table/tests/test_config.rs
+++ b/json_to_table/tests/test_config.rs
@@ -79,8 +79,6 @@ fn config_from_table_padding_zero_test() {
         .collapse()
         .to_string();
 
-    println!("{table}");
-
     assert_eq!(
         table,
         concat!(
@@ -173,8 +171,6 @@ fn color_test() {
         .collapse()
         .to_string();
 
-    println!("{}", table);
-
     assert_eq!(
         table,
         concat!(
diff --git a/tabled/tests/width_test.rs b/tabled/tests/width_test.rs
index b9dee33..627c440 100644
--- a/tabled/tests/width_test.rs
+++ b/tabled/tests/width_test.rs
@@ -2335,6 +2335,113 @@ fn wrap_keeping_words_0() {
     assert_eq!(table, expected,);
 }
 
+#[test]
+fn cell_truncate_multiline() {
+    let table = init_table::<3, 3, _, _>([
+        ((0, 1), "H\nel\nlo World"),
+        ((2, 2), "multi\nline string\n"),
+    ])
+    .with(Style::markdown())
+    .with(Modify::new(Columns::new(1..2).not(Rows::single(0))).with(Width::truncate(1).multiline()))
+    .to_string();
+
+    assert_eq!(
+        table,
+        static_table!(
+            "| N | column 0 |  column 1   | column 2 |"
+            "|---|----------|-------------|----------|"
+            "| 0 |    H     |     0-1     |   0-2    |"
+            "|   |    e     |             |          |"
+            "|   |    l     |             |          |"
+            "| 1 |    1     |     1-1     |   1-2    |"
+            "| 2 |    2     | multi       |   2-2    |"
+            "|   |          | line string |          |"
+            "|   |          |             |          |"
+        )
+    );
+}
+
+#[test]
+fn cell_truncate_multiline_with_suffix() {
+    let table = init_table::<3, 3, _, _>([
+        ((0, 1), "H\nel\nlo World"),
+        ((2, 2), "multi\nline string\n"),
+    ])
+    .with(Style::markdown())
+    .with(
+        Modify::new(Columns::new(1..2).not(Rows::single(0)))
+            .with(Width::truncate(1).multiline().suffix(".")),
+    )
+    .to_string();
+
+    assert_eq!(
+        table,
+        static_table!(
+            "| N | column 0 |  column 1   | column 2 |"
+            "|---|----------|-------------|----------|"
+            "| 0 |    .     |     0-1     |   0-2    |"
+            "|   |    .     |             |          |"
+            "|   |    .     |             |          |"
+            "| 1 |    .     |     1-1     |   1-2    |"
+            "| 2 |    .     | multi       |   2-2    |"
+            "|   |          | line string |          |"
+            "|   |          |             |          |"
+        )
+    );
+}
+
+#[test]
+fn table_truncate_multiline() {
+    let table = init_table::<3, 3, _, _>([
+        ((0, 1), "H\nel\nlo World"),
+        ((2, 2), "multi\nline string\n"),
+    ])
+    .with(Style::markdown())
+    .with(Width::truncate(20).multiline())
+    .to_string();
+
+    assert_eq!(
+        table,
+        static_table!(
+            "|  | c | colu | co |"
+            "|--|---|------|----|"
+            "|  | H | 0-1  | 0- |"
+            "|  | e |      |    |"
+            "|  | l |      |    |"
+            "|  | 1 | 1-1  | 1- |"
+            "|  | 2 | mult | 2- |"
+            "|  |   | line |    |"
+            "|  |   |      |    |"
+        )
+    );
+}
+
+#[test]
+fn table_truncate_multiline_with_suffix() {
+    let table = init_table::<3, 3, _, _>([
+        ((0, 1), "H\nel\nlo World"),
+        ((2, 2), "multi\nline string\n"),
+    ])
+    .with(Style::markdown())
+    .with(Width::truncate(20).suffix(".").multiline())
+    .to_string();
+
+    assert_eq!(
+        table,
+        static_table!(
+            "|  | . | col. | c. |"
+            "|--|---|------|----|"
+            "|  | . | 0-1  | 0. |"
+            "|  | . |      |    |"
+            "|  | . |      |    |"
+            "|  | . | 1-1  | 1. |"
+            "|  | . | mul. | 2. |"
+            "|  |   | lin. |    |"
+            "|  |   | .    |    |"
+        )
+    );
+}
+
 #[cfg(feature = "derive")]
 mod derived {
     use super::*;