From e6b4aa835ab5735c61ffaea91552e78532e5a462 Mon Sep 17 00:00:00 2001 From: Wez Furlong Date: Fri, 10 Jan 2020 23:58:12 -0800 Subject: [PATCH] re-wrap lines when resizing Adds logic to resize handling that will consider the original logical line length when the width of the terminal is changed. The intent is that this will cause the text to be re-flowed as if it had been printed into the terminal at the new width. Lines that were wrapped due to hittin the margin will be un-wrapped and made into a single logical line, and then split into chunks of the new width. This can cause new lines to be generated in the scrollback when making the terminal narrower. To avoid losing the top of the buffer in that case, the rewrapping logic will prune blank lines off the bottom. This is a pretty simplistic brute force algorithm: each of the lines will be visited and split, and for large scrollback buffers this could be relatively costly with a busy live resize. We don't have much choice in the current implementation. refs: https://github.com/wez/wezterm/issues/14 --- term/src/screen.rs | 66 ++++++++++++++++++++++++++++++++++-- term/src/terminalstate.rs | 8 +++-- term/src/test/mod.rs | 67 +++++++++++++++++++++++++++++++++++++ termwiz/src/surface/line.rs | 61 +++++++++++++++++++++++++++++++++ 4 files changed, 198 insertions(+), 4 deletions(-) diff --git a/term/src/screen.rs b/term/src/screen.rs index 2a88aec9e..59320bfbd 100644 --- a/term/src/screen.rs +++ b/term/src/screen.rs @@ -76,11 +76,73 @@ impl Screen { scrollback_size(&self.config, self.allow_scrollback) } + fn rewrap_lines(&mut self, physical_cols: usize) { + let mut rewrapped = VecDeque::new(); + let mut logical_line: Option = None; + for mut line in self.lines.drain(..) { + line.invalidate_implicit_hyperlinks(); + if line.last_cell_was_wrapped() { + line.set_last_cell_was_wrapped(false); + logical_line = Some(match logical_line.take() { + None => line, + Some(mut prior) => { + prior.append_line(line); + prior + } + }); + continue; + } + + let line = match logical_line.take() { + None => line, + Some(mut prior) => { + prior.append_line(line); + prior + } + }; + + if line.cells().len() <= physical_cols { + rewrapped.push_back(line); + } else { + for line in line.wrap(physical_cols) { + rewrapped.push_back(line); + } + } + } + self.lines = rewrapped; + + // If we resized narrower and generated additional lines, + // we may need to scroll the lines to make room. However, + // if the bottom line(s) are whitespace, we'll prune those + // out first in the rewrap case so that we don't lose any + // real information off the top of the scrollback + while self.lines.len() > self.physical_rows + && self.lines.back().map(Line::is_whitespace).unwrap_or(false) + { + self.lines.pop_back(); + } + + // If we resized wider and the rewrap resulted in fewer + // lines than the viewport size, pad us back out to the + // viewport size + while self.lines.len() < self.physical_rows { + self.lines.push_back(Line::with_width(physical_cols)); + } + } + /// Resize the physical, viewable portion of the screen pub fn resize(&mut self, physical_rows: usize, physical_cols: usize) { let physical_rows = physical_rows.max(1); let physical_cols = physical_cols.max(1); + if physical_cols != self.physical_cols { + // Check to see if we need to rewrap lines that were + // wrapped due to reaching the right hand side of the terminal. + // For each one that we find, we need to join it with its + // successor and then re-split it + self.rewrap_lines(physical_cols); + } + let capacity = physical_rows + self.scrollback_size(); let current_capacity = self.lines.capacity(); if capacity > current_capacity { @@ -211,11 +273,11 @@ impl Screen { } }; - let last = match self.stable_row_to_phys(range.end - 1) { + let last = match self.stable_row_to_phys(range.end.saturating_sub(1)) { Some(last) => last, None => { let last = self.lines.len() - 1; - return last - range_len..last + 1; + return last.saturating_sub(range_len)..last + 1; } }; diff --git a/term/src/terminalstate.rs b/term/src/terminalstate.rs index ddbc2eb70..c1006e284 100644 --- a/term/src/terminalstate.rs +++ b/term/src/terminalstate.rs @@ -1296,12 +1296,16 @@ impl TerminalState { let position = position.max(0); let rows = self.screen().physical_rows; - let avail_scrollback = self.screen().lines.len() - rows; + let avail_scrollback = self.screen().lines.len().saturating_sub(rows); let position = position.min(avail_scrollback as i64); self.viewport_offset = position; - let top = self.screen().lines.len() - (rows + position as usize); + let top = self + .screen() + .lines + .len() + .saturating_sub(rows + position as usize); { let screen = self.screen_mut(); for y in top..top + rows { diff --git a/term/src/test/mod.rs b/term/src/test/mod.rs index fe3737010..cfc210036 100644 --- a/term/src/test/mod.rs +++ b/term/src/test/mod.rs @@ -524,6 +524,73 @@ fn test_delete_lines() { term.assert_dirty_lines(&[1, 2, 3, 4], None); } +/// Test the behavior of wrapped lines when we resize the terminal +/// wider and then narrower. +#[test] +fn test_resize_wrap() { + const LINES: usize = 8; + let mut term = TestTerm::new(LINES, 4, 0); + term.print("111\r\n2222aa\r\n333\r\n"); + assert_visible_contents( + &term, + &[ + "111 ", "2222", "aa ", "333 ", " ", " ", " ", " ", + ], + ); + term.resize(LINES, 5, 0, 0); + assert_visible_contents( + &term, + &["111 ", "2222a", "a", "333 ", " ", " ", " ", " "], + ); + term.resize(LINES, 6, 0, 0); + assert_visible_contents( + &term, + &[ + "111 ", "2222aa", "333 ", " ", " ", " ", " ", " ", + ], + ); + term.resize(LINES, 7, 0, 0); + assert_visible_contents( + &term, + &[ + "111 ", "2222aa", "333 ", " ", " ", " ", " ", " ", + ], + ); + term.resize(LINES, 8, 0, 0); + assert_visible_contents( + &term, + &[ + "111 ", "2222aa", "333 ", " ", " ", " ", " ", " ", + ], + ); + + // Resize smaller again + term.resize(LINES, 7, 0, 0); + assert_visible_contents( + &term, + &[ + "111 ", "2222aa", "333 ", " ", " ", " ", " ", " ", + ], + ); + term.resize(LINES, 6, 0, 0); + assert_visible_contents( + &term, + &[ + "111 ", "2222aa", "333 ", " ", " ", " ", " ", " ", + ], + ); + term.resize(LINES, 5, 0, 0); + assert_visible_contents( + &term, + &["111 ", "2222a", "a", "333 ", " ", " ", " ", " "], + ); + term.resize(LINES, 4, 0, 0); + assert_visible_contents( + &term, + &["111 ", "2222", "aa", "333 ", " ", " ", " ", " "], + ); +} + #[test] fn test_scrollup() { let mut term = TestTerm::new(2, 1, 4); diff --git a/termwiz/src/surface/line.rs b/termwiz/src/surface/line.rs index bb7338ea2..41d45ec57 100644 --- a/termwiz/src/surface/line.rs +++ b/termwiz/src/surface/line.rs @@ -81,6 +81,37 @@ impl Line { self.bits |= LineBits::DIRTY; } + /// Wrap the line so that it fits within the provided width. + /// Returns the list of resultant line(s) + pub fn wrap(mut self, width: usize) -> Vec { + if let Some(end_idx) = self.cells.iter().rposition(|c| c.str() != " ") { + self.cells.resize(end_idx + 1, Cell::default()); + + let mut lines: Vec<_> = self + .cells + .chunks_mut(width) + .map(|chunk| { + let mut line = Line { + cells: chunk.to_vec(), + bits: LineBits::DIRTY, + }; + if line.cells.len() == width { + // Ensure that we don't forget that we wrapped + line.set_last_cell_was_wrapped(true); + } + line + }) + .collect(); + // The last of the chunks wasn't actually wrapped + lines + .last_mut() + .map(|line| line.set_last_cell_was_wrapped(false)); + lines + } else { + vec![self] + } + } + /// Check whether the dirty bit is set. /// If it is set, then something about the line has changed since /// the dirty bit was last cleared. @@ -348,6 +379,36 @@ impl Line { &self.cells } + /// Return true if the line consists solely of whitespace cells + pub fn is_whitespace(&self) -> bool { + self.cells.iter().all(|c| c.str() == " ") + } + + /// Return true if the last cell in the line has the wrapped attribute, + /// indicating that the following line is logically a part of this one. + pub fn last_cell_was_wrapped(&self) -> bool { + self.cells + .last() + .map(|c| c.attrs().wrapped()) + .unwrap_or(false) + } + + /// Adjust the value of the wrapped attribute on the last cell of this + /// line. + pub fn set_last_cell_was_wrapped(&mut self, wrapped: bool) { + if let Some(cell) = self.cells.last_mut() { + cell.attrs_mut().set_wrapped(wrapped); + } + } + + /// Concatenate the cells from other with this line, appending them + /// to this line. + /// This function is used by rewrapping logic when joining wrapped + /// lines back together. + pub fn append_line(&mut self, mut other: Line) { + self.cells.append(&mut other.cells); + } + /// mutable access the cell data, but the caller must take care /// to only mutate attributes rather than the cell textual content. /// Use set_cell if you need to modify the textual content of the