From 112d2a65f67e0305c43573ca0ae5cafbf882835b Mon Sep 17 00:00:00 2001 From: Brooks Rady Date: Mon, 6 Jul 2020 23:10:24 +0100 Subject: [PATCH] feat(widgets/paragraph): add option to preserve indentation when the text is wrapped (#327) --- examples/demo/ui.rs | 6 ++- examples/paragraph.rs | 8 ++-- examples/popup.rs | 6 +-- src/widgets/mod.rs | 2 +- src/widgets/paragraph.rs | 51 ++++++++++++++++---- src/widgets/reflow.rs | 95 +++++++++++++++++++++++++++++--------- tests/widgets_paragraph.rs | 8 ++-- 7 files changed, 130 insertions(+), 46 deletions(-) diff --git a/examples/demo/ui.rs b/examples/demo/ui.rs index f4a02be..b881251 100644 --- a/examples/demo/ui.rs +++ b/examples/demo/ui.rs @@ -6,7 +6,7 @@ use tui::{ widgets::canvas::{Canvas, Line, Map, MapResolution, Rectangle}, widgets::{ Axis, BarChart, Block, Borders, Chart, Dataset, Gauge, List, Paragraph, Row, Sparkline, - Table, Tabs, Text, + Table, Tabs, Text, Wrap, }, Frame, }; @@ -232,7 +232,9 @@ where .borders(Borders::ALL) .title("Footer") .title_style(Style::default().fg(Color::Magenta).modifier(Modifier::BOLD)); - let paragraph = Paragraph::new(text.iter()).block(block).wrap(true); + let paragraph = Paragraph::new(text.iter()) + .block(block) + .wrap(Wrap { trim: true }); f.render_widget(paragraph, area); } diff --git a/examples/paragraph.rs b/examples/paragraph.rs index ae71bd1..0ffafda 100644 --- a/examples/paragraph.rs +++ b/examples/paragraph.rs @@ -8,7 +8,7 @@ use tui::{ backend::TermionBackend, layout::{Alignment, Constraint, Direction, Layout}, style::{Color, Modifier, Style}, - widgets::{Block, Borders, Paragraph, Text}, + widgets::{Block, Borders, Paragraph, Text, Wrap}, Terminal, }; @@ -76,18 +76,18 @@ fn main() -> Result<(), Box> { let paragraph = Paragraph::new(text.iter()) .block(block.clone().title("Left, wrap")) .alignment(Alignment::Left) - .wrap(true); + .wrap(Wrap { trim: true }); f.render_widget(paragraph, chunks[1]); let paragraph = Paragraph::new(text.iter()) .block(block.clone().title("Center, wrap")) .alignment(Alignment::Center) - .wrap(true) + .wrap(Wrap { trim: true }) .scroll((scroll, 0)); f.render_widget(paragraph, chunks[2]); let paragraph = Paragraph::new(text.iter()) .block(block.clone().title("Right, wrap")) .alignment(Alignment::Right) - .wrap(true); + .wrap(Wrap { trim: true }); f.render_widget(paragraph, chunks[3]); })?; diff --git a/examples/popup.rs b/examples/popup.rs index ea7714c..6d8d995 100644 --- a/examples/popup.rs +++ b/examples/popup.rs @@ -10,7 +10,7 @@ use tui::{ backend::TermionBackend, layout::{Alignment, Constraint, Direction, Layout}, style::{Color, Modifier, Style}, - widgets::{Block, Borders, Paragraph, Text}, + widgets::{Block, Borders, Paragraph, Text, Wrap}, Terminal, }; @@ -83,12 +83,12 @@ fn main() -> Result<(), Box> { let paragraph = Paragraph::new(text.iter()) .block(Block::default().title("Left Block").borders(Borders::ALL)) - .alignment(Alignment::Left).wrap(true); + .alignment(Alignment::Left).wrap(Wrap { trim: true }); f.render_widget(paragraph, chunks[0]); let paragraph = Paragraph::new(text.iter()) .block(Block::default().title("Right Block").borders(Borders::ALL)) - .alignment(Alignment::Left).wrap(true); + .alignment(Alignment::Left).wrap(Wrap { trim: true }); f.render_widget(paragraph, chunks[1]); let block = Block::default().title("Popup").borders(Borders::ALL); diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs index b8c1c97..0bb1fa8 100644 --- a/src/widgets/mod.rs +++ b/src/widgets/mod.rs @@ -37,7 +37,7 @@ pub use self::chart::{Axis, Chart, Dataset, GraphType}; pub use self::clear::Clear; pub use self::gauge::Gauge; pub use self::list::{List, ListState}; -pub use self::paragraph::Paragraph; +pub use self::paragraph::{Paragraph, Wrap}; pub use self::sparkline::Sparkline; pub use self::table::{Row, Table, TableState}; pub use self::tabs::Tabs; diff --git a/src/widgets/paragraph.rs b/src/widgets/paragraph.rs index e763cb1..47d2ccd 100644 --- a/src/widgets/paragraph.rs +++ b/src/widgets/paragraph.rs @@ -21,7 +21,7 @@ fn get_line_offset(line_width: u16, text_area_width: u16, alignment: Alignment) /// # Examples /// /// ``` -/// # use tui::widgets::{Block, Borders, Paragraph, Text}; +/// # use tui::widgets::{Block, Borders, Paragraph, Text, Wrap}; /// # use tui::style::{Style, Color}; /// # use tui::layout::{Alignment}; /// let text = [ @@ -32,7 +32,7 @@ fn get_line_offset(line_width: u16, text_area_width: u16, alignment: Alignment) /// .block(Block::default().title("Paragraph").borders(Borders::ALL)) /// .style(Style::default().fg(Color::White).bg(Color::Black)) /// .alignment(Alignment::Center) -/// .wrap(true); +/// .wrap(Wrap { trim: true }); /// ``` #[derive(Debug, Clone)] pub struct Paragraph<'a, 't, T> @@ -43,18 +43,49 @@ where block: Option>, /// Widget style style: Style, - /// Wrap the text or not - wrapping: bool, + /// How to wrap the text + wrap: Option, /// The text to display text: T, /// Should we parse the text for embedded commands raw: bool, /// Scroll scroll: (u16, u16), - /// Aligenment of the text + /// Alignment of the text alignment: Alignment, } +/// Describes how to wrap text across lines. +/// +/// # Example +/// +/// ``` +/// # use tui::widgets::{Paragraph, Text, Wrap}; +/// let bullet_points = [Text::raw(r#"Some indented points: +/// - First thing goes here and is long so that it wraps +/// - Here is another point that is long enough to wrap"#)]; +/// +/// // With leading spaces trimmed (window width of 30 chars): +/// Paragraph::new(bullet_points.iter()).wrap(Wrap { trim: true }); +/// // Some indented points: +/// // - First thing goes here and is +/// // long so that it wraps +/// // - Here is another point that +/// // is long enough to wrap +/// +/// // But without trimming, indentation is preserved: +/// Paragraph::new(bullet_points.iter()).wrap(Wrap { trim: false }); +/// // Some indented points: +/// // - First thing goes here +/// // and is long so that it wraps +/// // - Here is another point +/// // that is long enough to wrap +#[derive(Debug, Clone, Copy)] +pub struct Wrap { + /// Should leading whitespace be trimmed + pub trim: bool, +} + impl<'a, 't, T> Paragraph<'a, 't, T> where T: Iterator>, @@ -63,7 +94,7 @@ where Paragraph { block: None, style: Default::default(), - wrapping: false, + wrap: None, raw: false, text, scroll: (0, 0), @@ -81,8 +112,8 @@ where self } - pub fn wrap(mut self, flag: bool) -> Paragraph<'a, 't, T> { - self.wrapping = flag; + pub fn wrap(mut self, wrap: Wrap) -> Paragraph<'a, 't, T> { + self.wrap = Some(wrap); self } @@ -133,8 +164,8 @@ where } }); - let mut line_composer: Box = if self.wrapping { - Box::new(WordWrapper::new(&mut styled, text_area.width)) + let mut line_composer: Box = if let Some(Wrap { trim }) = self.wrap { + Box::new(WordWrapper::new(&mut styled, text_area.width, trim)) } else { let mut line_composer = Box::new(LineTruncator::new(&mut styled, text_area.width)); if let Alignment::Left = self.alignment { diff --git a/src/widgets/reflow.rs b/src/widgets/reflow.rs index c5db940..d1ff8a3 100644 --- a/src/widgets/reflow.rs +++ b/src/widgets/reflow.rs @@ -20,18 +20,22 @@ pub struct WordWrapper<'a, 'b> { max_line_width: u16, current_line: Vec>, next_line: Vec>, + /// Removes the leading whitespace from lines + trim: bool, } impl<'a, 'b> WordWrapper<'a, 'b> { pub fn new( symbols: &'b mut dyn Iterator>, max_line_width: u16, + trim: bool, ) -> WordWrapper<'a, 'b> { WordWrapper { symbols, max_line_width, current_line: vec![], next_line: vec![], + trim, } } } @@ -60,8 +64,8 @@ impl<'a, 'b> LineComposer<'a> for WordWrapper<'a, 'b> { // Ignore characters wider that the total max width. if symbol.width() as u16 > self.max_line_width - // Skip leading whitespace. - || symbol_whitespace && symbol != "\n" && current_line_width == 0 + // Skip leading whitespace when trim is enabled. + || self.trim && symbol_whitespace && symbol != "\n" && current_line_width == 0 { continue; } @@ -233,7 +237,7 @@ mod test { use unicode_segmentation::UnicodeSegmentation; enum Composer { - WordWrapper, + WordWrapper { trim: bool }, LineTruncator, } @@ -241,7 +245,9 @@ mod test { let style = Default::default(); let mut styled = UnicodeSegmentation::graphemes(text, true).map(|g| Styled(g, style)); let mut composer: Box = match which { - Composer::WordWrapper => Box::new(WordWrapper::new(&mut styled, text_area_width)), + Composer::WordWrapper { trim } => { + Box::new(WordWrapper::new(&mut styled, text_area_width, trim)) + } Composer::LineTruncator => Box::new(LineTruncator::new(&mut styled, text_area_width)), }; let mut lines = vec![]; @@ -263,7 +269,8 @@ mod test { let width = 40; for i in 1..width { let text = "a".repeat(i); - let (word_wrapper, _) = run_composer(Composer::WordWrapper, &text, width as u16); + let (word_wrapper, _) = + run_composer(Composer::WordWrapper { trim: true }, &text, width as u16); let (line_truncator, _) = run_composer(Composer::LineTruncator, &text, width as u16); let expected = vec![text]; assert_eq!(word_wrapper, expected); @@ -276,7 +283,7 @@ mod test { let width = 20; let text = "abcdefg\nhijklmno\npabcdefg\nhijklmn\nopabcdefghijk\nlmnopabcd\n\n\nefghijklmno"; - let (word_wrapper, _) = run_composer(Composer::WordWrapper, text, width); + let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width); let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width); let wrapped: Vec<&str> = text.split('\n').collect(); @@ -288,7 +295,8 @@ mod test { fn line_composer_long_word() { let width = 20; let text = "abcdefghijklmnopabcdefghijklmnopabcdefghijklmnopabcdefghijklmno"; - let (word_wrapper, _) = run_composer(Composer::WordWrapper, text, width as u16); + let (word_wrapper, _) = + run_composer(Composer::WordWrapper { trim: true }, text, width as u16); let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width as u16); let wrapped = vec![ @@ -299,7 +307,7 @@ mod test { ]; assert_eq!( word_wrapper, wrapped, - "WordWrapper should deect the line cannot be broken on word boundary and \ + "WordWrapper should detect the line cannot be broken on word boundary and \ break it at line width limit." ); assert_eq!(line_truncator, vec![&text[..width]]); @@ -314,9 +322,12 @@ mod test { "abcd efghij klmnopabcd efgh ijklmnopabcdefg hijkl mnopab c d e f g h i j k l \ m n o"; let (word_wrapper_single_space, _) = - run_composer(Composer::WordWrapper, text, width as u16); - let (word_wrapper_multi_space, _) = - run_composer(Composer::WordWrapper, text_multi_space, width as u16); + run_composer(Composer::WordWrapper { trim: true }, text, width as u16); + let (word_wrapper_multi_space, _) = run_composer( + Composer::WordWrapper { trim: true }, + text_multi_space, + width as u16, + ); let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width as u16); let word_wrapped = vec![ @@ -336,7 +347,7 @@ mod test { fn line_composer_zero_width() { let width = 0; let text = "abcd efghij klmnopabcd efgh ijklmnopabcdefg hijkl mnopab "; - let (word_wrapper, _) = run_composer(Composer::WordWrapper, text, width); + let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width); let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width); let expected: Vec<&str> = Vec::new(); @@ -348,7 +359,7 @@ mod test { fn line_composer_max_line_width_of_1() { let width = 1; let text = "abcd efghij klmnopabcd efgh ijklmnopabcdefg hijkl mnopab "; - let (word_wrapper, _) = run_composer(Composer::WordWrapper, text, width); + let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width); let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width); let expected: Vec<&str> = UnicodeSegmentation::graphemes(text, true) @@ -363,7 +374,7 @@ mod test { let width = 1; let text = "コンピュータ上で文字を扱う場合、典型的には文字\naaaによる通信を行う場合にその\ 両端点では、"; - let (word_wrapper, _) = run_composer(Composer::WordWrapper, text, width); + let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width); let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width); assert_eq!(word_wrapper, vec!["", "a", "a", "a"]); assert_eq!(line_truncator, vec!["", "a"]); @@ -374,7 +385,7 @@ mod test { fn line_composer_word_wrapper_mixed_length() { let width = 20; let text = "abcd efghij klmnopabcdefghijklmnopabcdefghijkl mnopab cdefghi j klmno"; - let (word_wrapper, _) = run_composer(Composer::WordWrapper, text, width); + let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width); assert_eq!( word_wrapper, vec![ @@ -392,7 +403,8 @@ mod test { let width = 20; let text = "コンピュータ上で文字を扱う場合、典型的には文字による通信を行う場合にその両端点\ では、"; - let (word_wrapper, word_wrapper_width) = run_composer(Composer::WordWrapper, &text, width); + let (word_wrapper, word_wrapper_width) = + run_composer(Composer::WordWrapper { trim: true }, &text, width); let (line_truncator, _) = run_composer(Composer::LineTruncator, &text, width); assert_eq!(line_truncator, vec!["コンピュータ上で文字"]); let wrapped = vec![ @@ -410,7 +422,7 @@ mod test { fn line_composer_leading_whitespace_removal() { let width = 20; let text = "AAAAAAAAAAAAAAAAAAAA AAA"; - let (word_wrapper, _) = run_composer(Composer::WordWrapper, text, width); + let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width); let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width); assert_eq!(word_wrapper, vec!["AAAAAAAAAAAAAAAAAAAA", "AAA",]); assert_eq!(line_truncator, vec!["AAAAAAAAAAAAAAAAAAAA"]); @@ -421,7 +433,7 @@ mod test { fn line_composer_lots_of_spaces() { let width = 20; let text = " "; - let (word_wrapper, _) = run_composer(Composer::WordWrapper, text, width); + let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width); let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width); assert_eq!(word_wrapper, vec![""]); assert_eq!(line_truncator, vec![" "]); @@ -433,7 +445,7 @@ mod test { fn line_composer_char_plus_lots_of_spaces() { let width = 20; let text = "a "; - let (word_wrapper, _) = run_composer(Composer::WordWrapper, text, width); + let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width); let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width); // What's happening below is: the first line gets consumed, trailing spaces discarded, // after 20 of which a word break occurs (probably shouldn't). The second line break @@ -452,7 +464,8 @@ mod test { // hiragana and katakana... // This happens to also be a test case for mixed width because regular spaces are single width. let text = "コンピュ ータ上で文字を扱う場合、 典型的には文 字による 通信を行 う場合にその両端点では、"; - let (word_wrapper, word_wrapper_width) = run_composer(Composer::WordWrapper, text, width); + let (word_wrapper, word_wrapper_width) = + run_composer(Composer::WordWrapper { trim: true }, text, width); assert_eq!( word_wrapper, vec![ @@ -473,12 +486,50 @@ mod test { fn line_composer_word_wrapper_nbsp() { let width = 20; let text = "AAAAAAAAAAAAAAA AAAA\u{00a0}AAA"; - let (word_wrapper, _) = run_composer(Composer::WordWrapper, text, width); + let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width); assert_eq!(word_wrapper, vec!["AAAAAAAAAAAAAAA", "AAAA\u{00a0}AAA",]); // Ensure that if the character was a regular space, it would be wrapped differently. let text_space = text.replace("\u{00a0}", " "); - let (word_wrapper_space, _) = run_composer(Composer::WordWrapper, &text_space, width); + let (word_wrapper_space, _) = + run_composer(Composer::WordWrapper { trim: true }, &text_space, width); assert_eq!(word_wrapper_space, vec!["AAAAAAAAAAAAAAA AAAA", "AAA",]); } + + #[test] + fn line_composer_word_wrapper_preserve_indentation() { + let width = 20; + let text = "AAAAAAAAAAAAAAAAAAAA AAA"; + let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: false }, text, width); + assert_eq!(word_wrapper, vec!["AAAAAAAAAAAAAAAAAAAA", " AAA",]); + } + + #[test] + fn line_composer_word_wrapper_preserve_indentation_with_wrap() { + let width = 10; + let text = "AAA AAA AAAAA AA AAAAAA\n B\n C\n D"; + let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: false }, text, width); + assert_eq!( + word_wrapper, + vec!["AAA AAA", "AAAAA AA", "AAAAAA", " B", " C", " D"] + ); + } + + #[test] + fn line_composer_word_wrapper_preserve_indentation_lots_of_whitespace() { + let width = 10; + let text = " 4 Indent\n must wrap!"; + let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: false }, text, width); + assert_eq!( + word_wrapper, + vec![ + " ", + " 4", + "Indent", + " ", + " must", + "wrap!" + ] + ); + } } diff --git a/tests/widgets_paragraph.rs b/tests/widgets_paragraph.rs index 3fb0b3e..0bc7083 100644 --- a/tests/widgets_paragraph.rs +++ b/tests/widgets_paragraph.rs @@ -2,7 +2,7 @@ use tui::{ backend::TestBackend, buffer::Buffer, layout::Alignment, - widgets::{Block, Borders, Paragraph, Text}, + widgets::{Block, Borders, Paragraph, Text, Wrap}, Terminal, }; @@ -24,7 +24,7 @@ fn widgets_paragraph_can_wrap_its_content() { let paragraph = Paragraph::new(text.iter()) .block(Block::default().borders(Borders::ALL)) .alignment(alignment) - .wrap(true); + .wrap(Wrap { trim: true }); f.render_widget(paragraph, size); }) .unwrap(); @@ -90,7 +90,7 @@ fn widgets_paragraph_renders_double_width_graphemes() { let text = [Text::raw(s)]; let paragraph = Paragraph::new(text.iter()) .block(Block::default().borders(Borders::ALL)) - .wrap(true); + .wrap(Wrap { trim: true }); f.render_widget(paragraph, size); }) .unwrap(); @@ -122,7 +122,7 @@ fn widgets_paragraph_renders_mixed_width_graphemes() { let text = [Text::raw(s)]; let paragraph = Paragraph::new(text.iter()) .block(Block::default().borders(Borders::ALL)) - .wrap(true); + .wrap(Wrap { trim: true }); f.render_widget(paragraph, size); }) .unwrap();