From 89886eed513861e52134548874efedd1bcb83c84 Mon Sep 17 00:00:00 2001 From: Karoline Pauls Date: Sat, 8 Dec 2018 17:28:25 +0000 Subject: [PATCH] Paragraph: word wrapping --- examples/paragraph.rs | 19 +-- src/widgets/paragraph.rs | 259 +++++++++++++++++++++++++++------------ tests/paragraph.rs | 25 ++-- 3 files changed, 213 insertions(+), 90 deletions(-) diff --git a/examples/paragraph.rs b/examples/paragraph.rs index ce79bbf3..b4bbc7f2 100644 --- a/examples/paragraph.rs +++ b/examples/paragraph.rs @@ -30,12 +30,13 @@ fn main() -> Result<(), failure::Error> { let events = Events::new(); + let mut scroll: u16 = 0; loop { terminal.draw(|mut f| { let size = f.size(); // Words made "loooong" to demonstrate line breaking. - let s = "Veeeeeeeeeeeeeeeery loooooooooooooooooong striiiiiiiiiiiiiiiiiiiiiiiiiing. "; + let s = "Veeeeeeeeeeeeeeeery loooooooooooooooooong striiiiiiiiiiiiiiiiiiiiiiiiiing. "; let mut long_line = s.repeat(usize::from(size.width) / s.len() + 4); long_line.push('\n'); @@ -58,16 +59,16 @@ fn main() -> Result<(), failure::Error> { .split(size); let text = [ - Text::raw("This a line\n"), - Text::styled("This a line\n", Style::default().fg(Color::Red)), - Text::styled("This a line\n", Style::default().bg(Color::Blue)), + Text::raw("This is a line \n"), + Text::styled("This is a line \n", Style::default().fg(Color::Red)), + Text::styled("This is a line\n", Style::default().bg(Color::Blue)), Text::styled( - "This a longer line\n", + "This is a longer line\n", Style::default().modifier(Modifier::CrossedOut), ), - Text::raw(&long_line), + Text::styled(&long_line, Style::default().bg(Color::Green)), Text::styled( - "This a line\n", + "This is a line\n", Style::default().fg(Color::Green).modifier(Modifier::Italic), ), ]; @@ -88,6 +89,7 @@ fn main() -> Result<(), failure::Error> { .block(block.clone().title("Center, wrap")) .alignment(Alignment::Center) .wrap(true) + .scroll(scroll) .render(&mut f, chunks[2]); Paragraph::new(text.iter()) .block(block.clone().title("Right, wrap")) @@ -96,6 +98,9 @@ fn main() -> Result<(), failure::Error> { .render(&mut f, chunks[3]); })?; + scroll += 1; + scroll %= 10; + match events.next()? { Event::Input(key) => { if key == Key::Char('q') { diff --git a/src/widgets/paragraph.rs b/src/widgets/paragraph.rs index 32d407f2..4f0abe14 100644 --- a/src/widgets/paragraph.rs +++ b/src/widgets/paragraph.rs @@ -1,5 +1,4 @@ use either::Either; -use itertools::{multipeek, MultiPeek}; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; @@ -8,6 +7,172 @@ use layout::{Alignment, Rect}; use style::Style; use widgets::{Block, Text, Widget}; +#[derive(Copy, Clone, Debug)] +struct Styled<'a>(&'a str, Style); + +fn get_line_offset(line_width: u16, text_area_width: u16, alignment: Alignment) -> u16 { + match alignment { + Alignment::Center => (text_area_width / 2).saturating_sub(line_width / 2), + Alignment::Right => text_area_width.saturating_sub(line_width), + Alignment::Left => 0, + } +} + +/// A state machine to pack styled symbols into lines. +/// Cannot implement it as Iterator since it yields slices of the internal buffer (need streaming +/// iterators for that). +trait LineComposer<'a> { + fn read_line(&mut self) -> Option<(&[Styled<'a>], u16)>; +} + +/// A state machine that performs word wrapping. +struct LineWrapper<'a, 'b, It: Iterator>> { + symbols: &'b mut It, + max_line_width: u16, + current_line: Vec>, + next_line: Vec>, +} + +impl<'a, 'b, It: Iterator>> LineWrapper<'a, 'b, It> { + fn new(symbols: &'b mut It, max_line_width: u16) -> LineWrapper<'a, 'b, It> { + LineWrapper { + symbols, + max_line_width, + current_line: vec![], + next_line: vec![], + } + } +} + +impl<'a, 'b, It: Iterator>> LineComposer<'a> for LineWrapper<'a, 'b, It> { + fn read_line(&mut self) -> Option<(&[Styled<'a>], u16)> { + std::mem::swap(&mut self.current_line, &mut self.next_line); + self.next_line.truncate(0); + + let mut current_line_width = self + .current_line + .iter() + .map(|Styled(c, _)| c.width() as u16) + .sum(); + + let mut symbols_to_last_word_end: usize = 0; + let mut width_to_last_word_end: u16 = 0; + let mut prev_whitespace = false; + let mut symbols_exhausted = true; + for Styled(symbol, style) in &mut self.symbols { + symbols_exhausted = false; + + // Break on newline and discard it. + if symbol == "\n" { + if prev_whitespace { + current_line_width = width_to_last_word_end; + self.current_line.truncate(symbols_to_last_word_end); + } + break; + } + + // Mark the previous symbol as word end. + let symbol_whitespace = symbol.chars().all(&char::is_whitespace); + if symbol_whitespace && !prev_whitespace { + symbols_to_last_word_end = self.current_line.len(); + width_to_last_word_end = current_line_width; + } + + self.current_line.push(Styled(symbol, style)); + current_line_width += symbol.width() as u16; + + if current_line_width > self.max_line_width { + // If there was no word break in the text, wrap at the end of the line. + let (truncate_at, truncated_width) = if symbols_to_last_word_end != 0 { + (symbols_to_last_word_end, width_to_last_word_end) + } else { + (self.current_line.len() - 1, self.max_line_width) + }; + + // Push the remainder to the next line but strip leading whitespace: + { + let remainder = &self.current_line[truncate_at..]; + if let Some(remainder_nonwhite) = remainder + .iter() + .position(|Styled(c, _)| !c.chars().all(&char::is_whitespace)) + { + self.next_line + .extend_from_slice(&remainder[remainder_nonwhite..]); + } + } + self.current_line.truncate(truncate_at); + current_line_width = truncated_width; + break; + } + + prev_whitespace = symbol_whitespace; + } + + // Even if the iterator is exhausted, pass the previous remainder. + if symbols_exhausted && self.current_line.is_empty() { + None + } else { + Some((&self.current_line[..], current_line_width)) + } + } +} + +/// A state machine that truncates overhanging lines. +struct LineTruncator<'a, 'b, It: Iterator>> { + symbols: &'b mut It, + max_line_width: u16, + current_line: Vec>, + remainder: Option>, +} + +impl<'a, 'b, It: Iterator>> LineTruncator<'a, 'b, It> { + fn new(symbols: &'b mut It, max_line_width: u16) -> LineTruncator<'a, 'b, It> { + LineTruncator { + symbols, + max_line_width, + current_line: vec![], + remainder: None, + } + } +} + +impl<'a, 'b, It: Iterator>> LineComposer<'a> for LineTruncator<'a, 'b, It> { + fn read_line(&mut self) -> Option<(&[Styled<'a>], u16)> { + self.current_line.truncate(0); + + let mut current_line_width = 0; + if let Some(Styled(symbol, style)) = self.remainder { + self.current_line.push(Styled(symbol, style)); + current_line_width += symbol.width() as u16; + self.remainder = None; + } + + let mut symbols_exhausted = true; + for Styled(symbol, style) in &mut self.symbols { + symbols_exhausted = false; + + // Break on newline and discard it. + if symbol == "\n" { + break; + } + + if current_line_width + symbol.width() as u16 > self.max_line_width { + self.remainder = Some(Styled(symbol, style)); + break; + } + + current_line_width += symbol.width() as u16; + self.current_line.push(Styled(symbol, style)); + } + + if symbols_exhausted { + None + } else { + Some((&self.current_line[..], current_line_width)) + } + } +} + /// A widget to display some text. /// /// # Examples @@ -116,94 +281,38 @@ where self.background(&text_area, buf, self.style.bg); let style = self.style; - let styled = self.text.by_ref().flat_map(|t| match *t { + let mut styled = self.text.by_ref().flat_map(|t| match *t { Text::Raw(ref d) => { let data: &'t str = d; // coerce to &str - Either::Left(UnicodeSegmentation::graphemes(data, true).map(|g| (g, style))) + Either::Left(UnicodeSegmentation::graphemes(data, true).map(|g| Styled(g, style))) } Text::Styled(ref d, s) => { let data: &'t str = d; // coerce to &str - Either::Right(UnicodeSegmentation::graphemes(data, true).map(move |g| (g, s))) + Either::Right(UnicodeSegmentation::graphemes(data, true).map(move |g| Styled(g, s))) } }); - let mut styled = multipeek(styled); - - fn get_cur_line_len<'a, I: Iterator>( - styled: &mut MultiPeek, - ) -> u16 { - let mut line_len = 0; - while match &styled.peek() { - Some(&(x, _)) => x != "\n", - None => false, - } { - line_len += 1; - } - line_len - }; - let mut x = match self.alignment { - Alignment::Center => { - (text_area.width / 2).saturating_sub(get_cur_line_len(&mut styled) / 2) - } - Alignment::Right => (text_area.width).saturating_sub(get_cur_line_len(&mut styled)), - Alignment::Left => 0, + let mut line_composer: Box = if self.wrapping { + Box::new(LineWrapper::new(&mut styled, text_area.width)) + } else { + Box::new(LineTruncator::new(&mut styled, text_area.width)) }; let mut y = 0; - - let mut remove_leading_whitespaces = false; - while let Some((string, style)) = styled.next() { - if string == "\n" { - x = match self.alignment { - Alignment::Center => { - (text_area.width / 2).saturating_sub(get_cur_line_len(&mut styled) / 2) - } - - Alignment::Right => { - (text_area.width).saturating_sub(get_cur_line_len(&mut styled)) - } - Alignment::Left => 0, - }; - y += 1; - continue; - } - let token_end_index = x + string.width() as u16 - 1; - let last_column_index = text_area.width - 1; - if token_end_index > last_column_index { - if !self.wrapping { - continue; // Truncate the remainder of the line. - } else { - x = match self.alignment { - Alignment::Center => { - (text_area.width / 2).saturating_sub(get_cur_line_len(&mut styled) / 2) - } - - Alignment::Right => { - (text_area.width).saturating_sub(get_cur_line_len(&mut styled) + 1) - } - Alignment::Left => 0, - }; - y += 1; - remove_leading_whitespaces = true + while let Some((current_line, current_line_width)) = line_composer.read_line() { + if y >= self.scroll { + // TODO: make scroll and y usize. + let mut x = get_line_offset(current_line_width, text_area.width, self.alignment); + for Styled(symbol, style) in current_line { + buf.get_mut(text_area.left() + x, text_area.top() + y - self.scroll) + .set_symbol(symbol) + .set_style(*style); + x += symbol.width() as u16; } } - - if remove_leading_whitespaces && string == " " { - continue; - } - remove_leading_whitespaces = false; - - if y > text_area.height + self.scroll - 1 { + y += 1; + if y >= text_area.height + self.scroll { break; } - - if y < self.scroll { - continue; - } - - buf.get_mut(text_area.left() + x, text_area.top() + y - self.scroll) - .set_symbol(string) - .set_style(style); - x += string.width() as u16; } } } diff --git a/tests/paragraph.rs b/tests/paragraph.rs index bd332a8a..ee8fdd8a 100644 --- a/tests/paragraph.rs +++ b/tests/paragraph.rs @@ -30,14 +30,14 @@ fn paragraph_render_single_width() { let expected = Buffer::with_lines(vec![ "┌──────────────────┐", - "│The library is bas│", - "│ed on the principl│", - "│e of immediate ren│", - "│dering with interm│", - "│ediate buffers. Th│", - "│is means that at e│", - "│ach new frame you │", - "│should build all w│", + "│The library is │", + "│based on the │", + "│principle of │", + "│immediate │", + "│rendering with │", + "│intermediate │", + "│buffers. This │", + "│means that at each│", "└──────────────────┘", ]); assert_eq!(&expected, terminal.backend().buffer()); @@ -109,3 +109,12 @@ fn paragraph_render_mixed_width() { ]); assert_eq!(&expected, terminal.backend().buffer()); } + +// Untested cases: +// * text overflow (vertical) +// * text overflow (horizontal) +// * text align +// * text wrap (nbsp, \n, whitespace interactions, "aaaaaaaaaaa \n", multiple spaces) +// * text wrap - no whitespace in long text +// * whitespace truncation (word wrapping - left align, right align) +// * unicode line break hint?