From fe424b337684dd67c50a91b491ce5abbffefc230 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sun, 22 May 2022 20:41:55 +0200 Subject: [PATCH] Fix grapheme rendering Previously, when overwriting wide graphemes, those were not first cleared from the buffer. This could sometimes result in partial graphemes, which is an invalid buffer state. Now, when a wide grapheme is overwritten, it is removed from the buffer entirely and replaced with spaces. Those spaces retain the style of the removed grapheme. A similar thing now happens with graphemes that only partially overlap the buffer, either on the left or the right border. Those parts that are inside the buffer are rendered as spaces with the grapheme's style. --- examples/overlapping_graphemes.rs | 81 +++++++++++++++++++++++++++ src/buffer.rs | 93 ++++++++++++++++++++++++------- 2 files changed, 153 insertions(+), 21 deletions(-) create mode 100644 examples/overlapping_graphemes.rs diff --git a/examples/overlapping_graphemes.rs b/examples/overlapping_graphemes.rs new file mode 100644 index 0000000..67b6a31 --- /dev/null +++ b/examples/overlapping_graphemes.rs @@ -0,0 +1,81 @@ +use std::io; + +use crossterm::style::{ContentStyle, Stylize}; +use toss::frame::{Frame, Pos}; +use toss::terminal::Terminal; + +fn draw(f: &mut Frame) { + f.write( + Pos::new(0, 0), + "Writing over wide graphemes removes the entire overwritten grapheme.", + ContentStyle::default(), + ); + let under = ContentStyle::default().white().on_dark_blue(); + let over = ContentStyle::default().black().on_dark_yellow(); + for i in 0..6 { + let delta = i - 2; + f.write(Pos::new(2 + i * 7, 2), "a😀", under); + f.write(Pos::new(2 + i * 7, 3), "a😀", under); + f.write(Pos::new(2 + i * 7, 4), "a😀", under); + f.write(Pos::new(2 + i * 7 + delta, 3), "b", over); + f.write(Pos::new(2 + i * 7 + delta, 4), "😈", over); + } + + f.write( + Pos::new(0, 6), + "Wide graphemes at the edges of the screen apply their style, but are not", + ContentStyle::default(), + ); + f.write( + Pos::new(0, 7), + "actually rendered.", + ContentStyle::default(), + ); + let x1 = -1; + let x2 = f.size().width as i32 / 2 - 3; + let x3 = f.size().width as i32 - 5; + f.write(Pos::new(x1, 9), "123456", under); + f.write(Pos::new(x1, 10), "😀😀😀", under); + f.write(Pos::new(x2, 9), "123456", under); + f.write(Pos::new(x2, 10), "😀😀😀", under); + f.write(Pos::new(x3, 9), "123456", under); + f.write(Pos::new(x3, 10), "😀😀😀", under); + + let scientist = "👩‍🔬"; + f.write( + Pos::new(0, 12), + "Most terminals ignore the zero width joiner and display this female", + ContentStyle::default(), + ); + f.write( + Pos::new(0, 13), + "scientist emoji as a woman and a microscope: 👩‍🔬", + ContentStyle::default(), + ); + for i in 0..(f.width(scientist) + 4) { + f.write(Pos::new(2, 15 + i as i32), scientist, under); + f.write(Pos::new(i as i32, 15 + i as i32), "x", over); + } +} + +fn main() -> io::Result<()> { + // Automatically enters alternate screen and enables raw mode + let mut term = Terminal::new()?; + + loop { + // Must be called before rendering, otherwise the terminal has out-of-date + // size information and will present garbage. + term.autoresize()?; + + draw(term.frame()); + + if !term.present()? { + break; + } + } + + // Wait for input before exiting + let _ = crossterm::event::read(); + + Ok(()) +} diff --git a/src/buffer.rs b/src/buffer.rs index 5b9092e..e51f20e 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -38,8 +38,8 @@ pub struct Cell { pub offset: u8, } -impl Cell { - pub fn empty() -> Self { +impl Default for Cell { + fn default() -> Self { Self { content: " ".to_string().into_boxed_str(), style: ContentStyle::default(), @@ -70,7 +70,15 @@ impl Buffer { pub(crate) fn at(&self, x: u16, y: u16) -> &Cell { assert!(x < self.size.width); assert!(y < self.size.height); - &self.data[self.index(x, y)] + let i = self.index(x, y); + &self.data[i] + } + + fn at_mut(&mut self, x: u16, y: u16) -> &mut Cell { + assert!(x < self.size.width); + assert!(y < self.size.height); + let i = self.index(x, y); + &mut self.data[i] } pub fn size(&self) -> Size { @@ -83,7 +91,7 @@ impl Buffer { /// correct size. pub fn resize(&mut self, size: Size) { if size == self.size() { - self.data.fill_with(Cell::empty); + self.data.fill_with(Cell::default); } else { let width: usize = size.width.into(); let height: usize = size.height.into(); @@ -91,7 +99,7 @@ impl Buffer { self.size = size; self.data.clear(); - self.data.resize_with(len, Cell::empty); + self.data.resize_with(len, Cell::default); } } @@ -99,7 +107,24 @@ impl Buffer { /// /// `buf.reset()` is equivalent to `buf.resize(buf.size())`. pub fn reset(&mut self) { - self.data.fill_with(Cell::empty); + self.data.fill_with(Cell::default); + } + + /// Remove the grapheme at the specified coordinates from the buffer. + /// + /// Removes the entire grapheme, not just the cell at the coordinates. + /// Preserves the style of the affected cells. Works even if the coordinates + /// don't point to the beginning of the grapheme. + fn erase(&mut self, x: u16, y: u16) { + let cell = self.at(x, y); + let width: u16 = cell.width.into(); + let offset: u16 = cell.offset.into(); + for x in (x - offset)..(x - offset + width) { + let cell = self.at_mut(x, y); + let style = cell.style; + *cell = Cell::default(); + cell.style = style; + } } pub fn write( @@ -109,31 +134,57 @@ impl Buffer { content: &str, style: ContentStyle, ) { + // If we're not even visible, there's nothing to do if pos.y < 0 || pos.y >= self.size.height as i32 { return; } + let y = pos.y as u16; for grapheme in content.graphemes(true) { let width = widthdb.width(grapheme); - if pos.x >= 0 && pos.x + width as i32 <= self.size.width as i32 { - // Grapheme fits on buffer in its entirety - let grapheme = grapheme.to_string().into_boxed_str(); - - for offset in 0..width { - let i = self.index(pos.x as u16 + offset as u16, pos.y as u16); - self.data[i] = Cell { - content: grapheme.clone(), - style, - width, - offset, - }; - } - } - + self.write_grapheme(pos.x, y, width, grapheme, style); pos.x += width as i32; } } + /// Assumes that `pos.y` is in range. + fn write_grapheme(&mut self, x: i32, y: u16, width: u8, grapheme: &str, style: ContentStyle) { + let min_x = 0; + let max_x = self.size.width as i32 - 1; // Last possible cell + + let start_x = x; + let end_x = x + width as i32 - 1; // Coordinate of last cell + + if start_x > max_x || end_x < min_x { + return; // Not visible + } + + if start_x >= min_x && end_x <= max_x { + // Fully visible, write actual grapheme + for offset in 0..width { + let x = start_x as u16 + offset as u16; + self.erase(x, y); + *self.at_mut(x, y) = Cell { + content: grapheme.to_string().into_boxed_str(), + style, + width, + offset, + }; + } + } else { + // Partially visible, write empty cells with correct style + let start_x = start_x.max(0) as u16; + let end_x = end_x.min(max_x) as u16; + for x in start_x..=end_x { + self.erase(x, y); + *self.at_mut(x, y) = Cell { + style, + ..Default::default() + }; + } + } + } + pub fn cells(&self) -> Cells<'_> { Cells { buffer: self,