From 1f41a610083bb941024bd0116df1f0ac37e9cf33 Mon Sep 17 00:00:00 2001 From: Alex Pasmantier <47638216+alexpasmantier@users.noreply.github.com> Date: Thu, 23 Jan 2025 22:52:52 +0100 Subject: [PATCH] perf(paragraph): avoid unnecessary work when rendering (#1622) Improve render times for paragraphs that are scrolled. Currently all `LineComposer`s are considered to be state machines which means rendering a paragraph with a given Y offset requires computing the entire state up to Y before being able to render from Y onwards. While this makes sense for Composers such as the `WordWrapper` (where one needs to consider all previous lines to determine where a given line will end up), it means it also penalizes Composers which can render a given line "statelessely" (such as the `LineTruncator`) which actually end up doing a lot of unnecessary work (and on the critical rendering path) when the offset gets high. Co-authored-by: Josh McKinney --- ratatui-widgets/src/paragraph.rs | 83 ++++++++++++++++--------------- ratatui-widgets/src/reflow.rs | 10 ++-- ratatui/benches/main/paragraph.rs | 13 ++--- 3 files changed, 54 insertions(+), 52 deletions(-) diff --git a/ratatui-widgets/src/paragraph.rs b/ratatui-widgets/src/paragraph.rs index 9e60c9e2..d697b76c 100644 --- a/ratatui-widgets/src/paragraph.rs +++ b/ratatui-widgets/src/paragraph.rs @@ -14,14 +14,6 @@ use crate::{ reflow::{LineComposer, LineTruncator, WordWrapper, WrappedLine}, }; -const 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 widget to display some text. /// /// It is used to display a block of text. The text can be styled and aligned. It can also be @@ -446,49 +438,58 @@ impl Paragraph<'_> { }); if let Some(Wrap { trim }) = self.wrap { - let line_composer = WordWrapper::new(styled, text_area.width, trim); - self.render_text(line_composer, text_area, buf); + let mut line_composer = WordWrapper::new(styled, text_area.width, trim); + // compute the lines iteratively until we reach the desired scroll offset. + for _ in 0..self.scroll.y { + if line_composer.next_line().is_none() { + return; + } + } + render_lines(line_composer, text_area, buf); } else { - let mut line_composer = LineTruncator::new(styled, text_area.width); + // avoid unnecessary work by skipping directly to the relevant line before rendering + let lines = styled.skip(self.scroll.y as usize); + let mut line_composer = LineTruncator::new(lines, text_area.width); line_composer.set_horizontal_offset(self.scroll.x); - self.render_text(line_composer, text_area, buf); + render_lines(line_composer, text_area, buf); } } } -impl<'a> Paragraph<'a> { - fn render_text>(&self, mut composer: C, area: Rect, buf: &mut Buffer) { - let mut y = 0; - while let Some(WrappedLine { - line: current_line, - width: current_line_width, - alignment: current_line_alignment, - }) = composer.next_line() - { - if y >= self.scroll.y { - let mut x = get_line_offset(current_line_width, area.width, current_line_alignment); - for StyledGrapheme { symbol, style } in current_line { - let width = symbol.width(); - if width == 0 { - continue; - } - // If the symbol is empty, the last char which rendered last time will - // leave on the line. It's a quick fix. - let symbol = if symbol.is_empty() { " " } else { symbol }; - buf[(area.left() + x, area.top() + y - self.scroll.y)] - .set_symbol(symbol) - .set_style(*style); - x += width as u16; - } - } - y += 1; - if y >= area.height + self.scroll.y { - break; - } +fn render_lines<'a, C: LineComposer<'a>>(mut composer: C, area: Rect, buf: &mut Buffer) { + let mut y = 0; + while let Some(ref wrapped) = composer.next_line() { + render_line(wrapped, area, buf, y); + y += 1; + if y >= area.height { + break; } } } +fn render_line(wrapped: &WrappedLine<'_, '_>, area: Rect, buf: &mut Buffer, y: u16) { + let mut x = get_line_offset(wrapped.width, area.width, wrapped.alignment); + for StyledGrapheme { symbol, style } in wrapped.graphemes { + let width = symbol.width(); + if width == 0 { + continue; + } + // Make sure to overwrite any previous character with a space (rather than a zero-width) + let symbol = if symbol.is_empty() { " " } else { symbol }; + let position = Position::new(area.left() + x, area.top() + y); + buf[position].set_symbol(symbol).set_style(*style); + x += u16::try_from(width).unwrap_or(u16::MAX); + } +} + +const 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, + } +} + impl Styled for Paragraph<'_> { type Item = Self; diff --git a/ratatui-widgets/src/reflow.rs b/ratatui-widgets/src/reflow.rs index ce50ba12..4aefa9e0 100644 --- a/ratatui-widgets/src/reflow.rs +++ b/ratatui-widgets/src/reflow.rs @@ -15,7 +15,7 @@ pub trait LineComposer<'a> { /// A line that has been wrapped to a certain width. pub struct WrappedLine<'lend, 'text> { /// One line reflowed to the correct width - pub line: &'lend [StyledGrapheme<'text>], + pub graphemes: &'lend [StyledGrapheme<'text>], /// The width of the line pub width: u16, /// Whether the line was aligned left or right @@ -215,7 +215,7 @@ where self.replace_current_line(line); return Some(WrappedLine { - line: &self.current_line, + graphemes: &self.current_line, width: line_width, alignment: self.current_alignment, }); @@ -321,7 +321,7 @@ where None } else { Some(WrappedLine { - line: &self.current_line, + graphemes: &self.current_line, width: current_line_width, alignment: current_alignment, }) @@ -385,12 +385,12 @@ mod tests { let mut widths = vec![]; let mut alignments = vec![]; while let Some(WrappedLine { - line: styled, + graphemes, width, alignment, }) = composer.next_line() { - let line = styled + let line = graphemes .iter() .map(|StyledGrapheme { symbol, .. }| *symbol) .collect::(); diff --git a/ratatui/benches/main/paragraph.rs b/ratatui/benches/main/paragraph.rs index cb98ca2e..58089db7 100644 --- a/ratatui/benches/main/paragraph.rs +++ b/ratatui/benches/main/paragraph.rs @@ -8,18 +8,19 @@ use ratatui::{ /// because the scroll offset is a u16, the maximum number of lines that can be scrolled is 65535. /// This is a limitation of the current implementation and may be fixed by changing the type of the /// scroll offset to a u32. -const MAX_SCROLL_OFFSET: u16 = u16::MAX; const NO_WRAP_WIDTH: u16 = 200; const WRAP_WIDTH: u16 = 100; +const PARAGRAPH_DEFAULT_HEIGHT: u16 = 50; /// Benchmark for rendering a paragraph with a given number of lines. The design of this benchmark /// allows comparison of the performance of rendering a paragraph with different numbers of lines. /// as well as comparing with the various settings on the scroll and wrap features. fn paragraph(c: &mut Criterion) { let mut group = c.benchmark_group("paragraph"); - for line_count in [64, 2048, MAX_SCROLL_OFFSET] { + for line_count in [64, 2048, u16::MAX] { let lines = random_lines(line_count); let lines = lines.as_str(); + let y_scroll = line_count - PARAGRAPH_DEFAULT_HEIGHT; // benchmark that measures the overhead of creating a paragraph separately from rendering group.bench_with_input(BenchmarkId::new("new", line_count), lines, |b, lines| { @@ -36,14 +37,14 @@ fn paragraph(c: &mut Criterion) { // scroll the paragraph by half the number of lines and render group.bench_with_input( BenchmarkId::new("render_scroll_half", line_count), - &Paragraph::new(lines).scroll((0, line_count / 2)), + &Paragraph::new(lines).scroll((y_scroll / 2, 0)), |bencher, paragraph| render(bencher, paragraph, NO_WRAP_WIDTH), ); // scroll the paragraph by the full number of lines and render group.bench_with_input( BenchmarkId::new("render_scroll_full", line_count), - &Paragraph::new(lines).scroll((0, line_count)), + &Paragraph::new(lines).scroll((y_scroll, 0)), |bencher, paragraph| render(bencher, paragraph, NO_WRAP_WIDTH), ); @@ -59,7 +60,7 @@ fn paragraph(c: &mut Criterion) { BenchmarkId::new("render_wrap_scroll_full", line_count), &Paragraph::new(lines) .wrap(Wrap { trim: false }) - .scroll((0, line_count)), + .scroll((y_scroll, 0)), |bencher, paragraph| render(bencher, paragraph, WRAP_WIDTH), ); } @@ -68,7 +69,7 @@ fn paragraph(c: &mut Criterion) { /// render the paragraph into a buffer with the given width fn render(bencher: &mut Bencher, paragraph: &Paragraph, width: u16) { - let mut buffer = Buffer::empty(Rect::new(0, 0, width, 50)); + let mut buffer = Buffer::empty(Rect::new(0, 0, width, PARAGRAPH_DEFAULT_HEIGHT)); // We use `iter_batched` to clone the value in the setup function. // See https://github.com/ratatui/ratatui/pull/377. bencher.iter_batched(