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 <joshka@users.noreply.github.com>
This commit is contained in:
Alex Pasmantier 2025-01-23 22:52:52 +01:00 committed by GitHub
parent 7ad9c29eac
commit 1f41a61008
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 54 additions and 52 deletions

View File

@ -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<C: LineComposer<'a>>(&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;

View File

@ -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::<String>();

View File

@ -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(