From 3fd9e23851a0077e2ab571c0f72f5be7ed67d960 Mon Sep 17 00:00:00 2001 From: Karoline Pauls Date: Fri, 14 Dec 2018 17:33:05 +0000 Subject: [PATCH] Buffer: correct diffing of buffers with multi-width characters Resolves #104 --- src/buffer.rs | 258 ++++++++++++++++++++++++++++++++++++++++++--- src/terminal.rs | 25 ++--- tests/paragraph.rs | 29 +++-- 3 files changed, 264 insertions(+), 48 deletions(-) diff --git a/src/buffer.rs b/src/buffer.rs index 1b379237..5bef6452 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -3,6 +3,7 @@ use std::fmt; use std::usize; use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; use layout::Rect; use style::{Color, Modifier, Style}; @@ -117,8 +118,25 @@ impl fmt::Debug for Buffer { writeln!(f, "Buffer: {:?}", self.area)?; f.write_str("Content (quoted lines):\n")?; for cells in self.content.chunks(self.area.width as usize) { - let line: String = cells.iter().map(|cell| &cell.symbol[..]).collect(); - f.write_fmt(format_args!("{:?},\n", line))?; + let mut line = String::new(); + let mut overwritten = vec![]; + let mut skip: usize = 0; + for (x, c) in cells.iter().enumerate() { + if skip == 0 { + line.push_str(&c.symbol); + } else { + overwritten.push((x, &c.symbol)) + } + skip = std::cmp::max(skip, c.symbol.width()).saturating_sub(1); + } + f.write_fmt(format_args!("{:?},", line))?; + if !overwritten.is_empty() { + f.write_fmt(format_args!( + " Hidden by multi-width symbols: {:?}", + overwritten + ))?; + } + f.write_str("\n")?; } f.write_str("Style:\n")?; for cells in self.content.chunks(self.area.width as usize) { @@ -162,10 +180,7 @@ impl Buffer { { let height = lines.len() as u16; let width = lines.iter().fold(0, |acc, item| { - std::cmp::max( - acc, - UnicodeSegmentation::graphemes(item.as_ref(), true).count() as u16, - ) + std::cmp::max(acc, item.as_ref().width() as u16) }); let mut buffer = Buffer::empty(Rect { x: 0, @@ -295,18 +310,30 @@ impl Buffer { /// Print at most the first n characters of a string if enough space is available /// until the end of the line - pub fn set_stringn(&mut self, x: u16, y: u16, string: S, limit: usize, style: Style) + pub fn set_stringn(&mut self, x: u16, y: u16, string: S, width: usize, style: Style) where S: AsRef, { let mut index = self.index_of(x, y); + let mut x_offset = x as usize; let graphemes = UnicodeSegmentation::graphemes(string.as_ref(), true); - let max_index = min((self.area.right() - x) as usize, limit); - for s in graphemes.take(max_index) { - self.content[index].symbol.clear(); - self.content[index].symbol.push_str(s); - self.content[index].style = style; - index += 1; + let max_offset = min(self.area.right() as usize, width.saturating_add(x as usize)); + for s in graphemes { + let width = s.width(); + // `x_offset + width > max_offset` could be integer overflow on 32-bit machines if we + // change dimenstions to usize or u32 and someone resizes the terminal to 1x2^32. + if width > max_offset.saturating_sub(x_offset) { + break; + } + + self.content[index].set_symbol(s); + self.content[index].set_style(style); + // Reset following cells if multi-width (they would be hidden by the grapheme), + for i in index + 1..index + width { + self.content[i].reset(); + } + index += width; + x_offset += width; } } @@ -362,12 +389,72 @@ impl Buffer { } self.area = area; } + + /// Builds a minimal sequence of coordinates and Cells necessary to update the UI from + /// self to other. + /// + /// We're assuming that buffers are well-formed, that is no double-width cell is followed by + /// a non-blank cell. + /// + /// # Multi-width characters handling: + /// + /// ```text + /// (Index:) `01` + /// Prev: `コ` + /// Next: `aa` + /// Updates: `0: a, 1: a' + /// ``` + /// + /// ```text + /// (Index:) `01` + /// Prev: `a ` + /// Next: `コ` + /// Updates: `0: コ` (double width symbol at index 0 - skip index 1) + /// ``` + /// + /// ```text + /// (Index:) `012` + /// Prev: `aaa` + /// Next: `aコ` + /// Updates: `0: a, 1: コ` (double width symbol at index 1 - skip index 2) + /// ``` + pub fn diff<'a>(&self, other: &'a Buffer) -> Vec<(u16, u16, &'a Cell)> { + let previous_buffer = &self.content; + let next_buffer = &other.content; + let width = self.area.width; + + let mut updates: Vec<(u16, u16, &Cell)> = vec![]; + // Cells invalidated by drawing/replacing preceeding multi-width characters: + let mut invalidated: usize = 0; + // Cells from the current buffer to skip due to preceeding multi-width characters taking their + // place (the skipped cells should be blank anyway): + let mut to_skip: usize = 0; + for (i, (current, previous)) in next_buffer.iter().zip(previous_buffer.iter()).enumerate() { + if (current != previous || invalidated > 0) && to_skip == 0 { + let x = i as u16 % width; + let y = i as u16 / width; + updates.push((x, y, &next_buffer[i])); + } + + to_skip = current.symbol.width().saturating_sub(1); + + let affected_width = std::cmp::max(current.symbol.width(), previous.symbol.width()); + invalidated = std::cmp::max(affected_width, invalidated).saturating_sub(1); + } + updates + } } #[cfg(test)] mod tests { use super::*; + fn cell(s: &str) -> Cell { + let mut cell = Cell::default(); + cell.set_symbol(s); + cell + } + #[test] fn it_translates_to_and_from_coordinates() { let rect = Rect::new(200, 100, 50, 80); @@ -401,4 +488,149 @@ mod tests { // width is 10; zero-indexed means that 10 would be the 11th cell. buf.index_of(10, 0); } + + #[test] + fn buffer_set_string() { + let area = Rect::new(0, 0, 5, 1); + let mut buffer = Buffer::empty(area); + + // Zero-width + buffer.set_stringn(0, 0, "aaa", 0, Style::default()); + assert_eq!(buffer, Buffer::with_lines(vec![" "])); + + buffer.set_string(0, 0, "aaa", Style::default()); + assert_eq!(buffer, Buffer::with_lines(vec!["aaa "])); + + // Width limit: + buffer.set_stringn(0, 0, "bbbbbbbbbbbbbb", 4, Style::default()); + assert_eq!(buffer, Buffer::with_lines(vec!["bbbb "])); + + buffer.set_string(0, 0, "12345", Style::default()); + assert_eq!(buffer, Buffer::with_lines(vec!["12345"])); + + // Width truncation: + buffer.set_string(0, 0, "123456", Style::default()); + assert_eq!(buffer, Buffer::with_lines(vec!["12345"])); + } + + #[test] + fn buffer_set_string_double_width() { + let area = Rect::new(0, 0, 5, 1); + let mut buffer = Buffer::empty(area); + buffer.set_string(0, 0, "コン", Style::default()); + assert_eq!(buffer, Buffer::with_lines(vec!["コン "])); + + // Only 1 space left. + buffer.set_string(0, 0, "コンピ", Style::default()); + assert_eq!(buffer, Buffer::with_lines(vec!["コン "])); + } + + #[test] + fn buffer_with_lines() { + let buffer = Buffer::with_lines(vec![ + "┌────────┐", + "│コンピュ│", + "│ーa 上で│", + "└────────┘", + ]); + assert_eq!(buffer.area.x, 0); + assert_eq!(buffer.area.y, 0); + assert_eq!(buffer.area.width, 10); + assert_eq!(buffer.area.height, 4); + } + + #[test] + fn buffer_diffing_empty_empty() { + let area = Rect::new(0, 0, 40, 40); + let prev = Buffer::empty(area); + let next = Buffer::empty(area); + let diff = prev.diff(&next); + assert_eq!(diff, vec![]); + } + + #[test] + fn buffer_diffing_empty_filled() { + let area = Rect::new(0, 0, 40, 40); + let prev = Buffer::empty(area); + let next = Buffer::filled(area, Cell::default().set_symbol("a")); + let diff = prev.diff(&next); + assert_eq!(diff.len(), 40 * 40); + } + + #[test] + fn buffer_diffing_filled_filled() { + let area = Rect::new(0, 0, 40, 40); + let prev = Buffer::filled(area, Cell::default().set_symbol("a")); + let next = Buffer::filled(area, Cell::default().set_symbol("a")); + let diff = prev.diff(&next); + assert_eq!(diff, vec![]); + } + + #[test] + fn buffer_diffing_single_width() { + let prev = Buffer::with_lines(vec![ + " ", + "┌Title─┐ ", + "│ │ ", + "│ │ ", + "└──────┘ ", + ]); + let next = Buffer::with_lines(vec![ + " ", + "┌TITLE─┐ ", + "│ │ ", + "│ │ ", + "└──────┘ ", + ]); + let diff = prev.diff(&next); + assert_eq!( + diff, + vec![ + (2, 1, &cell("I")), + (3, 1, &cell("T")), + (4, 1, &cell("L")), + (5, 1, &cell("E")), + ] + ); + } + + #[test] + #[rustfmt::skip] + fn buffer_diffing_multi_width() { + let prev = Buffer::with_lines(vec![ + "┌Title─┐ ", + "└──────┘ ", + ]); + let next = Buffer::with_lines(vec![ + "┌称号──┐ ", + "└──────┘ ", + ]); + let diff = prev.diff(&next); + assert_eq!( + diff, + vec![ + (1, 0, &cell("称")), + // Skipped "i" + (3, 0, &cell("号")), + // Skipped "l" + (5, 0, &cell("─")), + ] + ); + } + + #[test] + fn buffer_diffing_multi_width_offset() { + let prev = Buffer::with_lines(vec!["┌称号──┐"]); + let next = Buffer::with_lines(vec!["┌─称号─┐"]); + + let diff = prev.diff(&next); + assert_eq!( + diff, + vec![ + (1, 0, &cell("─")), + (2, 0, &cell("称")), + (4, 0, &cell("号")), + ] + ); + } } diff --git a/src/terminal.rs b/src/terminal.rs index 9a79e9fb..afae2122 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -97,26 +97,13 @@ where &mut self.backend } - /// Builds a string representing the minimal escape sequences and characters set necessary to - /// update the UI and writes it to stdout. + /// Obtains a difference between the previous and the current buffer and passes it to the + /// current backend for drawing. pub fn flush(&mut self) -> io::Result<()> { - let width = self.buffers[self.current].area.width; - let content = self.buffers[self.current] - .content - .iter() - .zip(self.buffers[1 - self.current].content.iter()) - .enumerate() - .filter_map(|(i, (c, p))| { - if c != p { - let i = i as u16; - let x = i % width; - let y = i / width; - Some((x, y, c)) - } else { - None - } - }); - self.backend.draw(content) + let previous_buffer = &self.buffers[1 - self.current]; + let current_buffer = &self.buffers[self.current]; + let updates = previous_buffer.diff(current_buffer); + self.backend.draw(updates.into_iter()) } /// Updates the Terminal so that internal buffers match the requested size. Requested size will diff --git a/tests/paragraph.rs b/tests/paragraph.rs index 9283f3c6..33152cde 100644 --- a/tests/paragraph.rs +++ b/tests/paragraph.rs @@ -99,18 +99,15 @@ fn paragraph_render_double_width() { .unwrap(); let expected = Buffer::with_lines(vec![ - // This is "OK" - these are double-width characters. In terminal each occupies 2 spaces, - // which means the buffer contains a Cell with a full grapheme in it, followed by a vacant - // one. Here however, we have plain text, so each character is visibly followed by a space. "┌────────┐", - "│コ ン ピ ュ │", - "│ー タ 上 で │", - "│文 字 を 扱 │", - "│う 場 合 、 │", - "│典 型 的 に │", - "│は 文 字 に │", - "│よ る 通 信 │", - "│を 行 う 場 │", + "│コンピュ│", + "│ータ上で│", + "│文字を扱│", + "│う場合、│", + "│典型的に│", + "│は文字に│", + "│よる通信│", + "│を行う場│", "└────────┘", ]); assert_eq!(&expected, terminal.backend().buffer()); @@ -136,11 +133,11 @@ fn paragraph_render_mixed_width() { let expected = Buffer::with_lines(vec![ // The internal width is 8 so only 4 slots for double-width characters. "┌────────┐", - "│aコ ン ピ │", // Here we have 1 latin character so only 3 double-width ones can fit. - "│ュ ー タ 上 │", - "│で 文 字 を │", - "│扱 う 場 合 │", - "│、 │", + "│aコンピ │", // Here we have 1 latin character so only 3 double-width ones can fit. + "│ュータ上│", + "│で文字を│", + "│扱う場合│", + "│、 │", "└────────┘", ]); assert_eq!(&expected, terminal.backend().buffer());