Skip to content
This repository has been archived by the owner on Aug 6, 2023. It is now read-only.

Commit

Permalink
Paragraph: word wrapping
Browse files Browse the repository at this point in the history
  • Loading branch information
karolinepauls committed Dec 10, 2018
1 parent dbd293e commit 89886ee
Show file tree
Hide file tree
Showing 3 changed files with 213 additions and 90 deletions.
19 changes: 12 additions & 7 deletions examples/paragraph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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),
),
];
Expand All @@ -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"))
Expand All @@ -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') {
Expand Down
259 changes: 184 additions & 75 deletions src/widgets/paragraph.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
use either::Either;
use itertools::{multipeek, MultiPeek};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;

Expand All @@ -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<Item = Styled<'a>>> {
symbols: &'b mut It,
max_line_width: u16,
current_line: Vec<Styled<'a>>,
next_line: Vec<Styled<'a>>,
}

impl<'a, 'b, It: Iterator<Item = Styled<'a>>> 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<Item = Styled<'a>>> 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<Item = Styled<'a>>> {
symbols: &'b mut It,
max_line_width: u16,
current_line: Vec<Styled<'a>>,
remainder: Option<Styled<'a>>,
}

impl<'a, 'b, It: Iterator<Item = Styled<'a>>> 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<Item = Styled<'a>>> 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
Expand Down Expand Up @@ -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<Item = (&'a str, Style)>>(
styled: &mut MultiPeek<I>,
) -> 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<dyn LineComposer> = 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;
}
}
}
25 changes: 17 additions & 8 deletions tests/paragraph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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?

0 comments on commit 89886ee

Please sign in to comment.