mirror of
https://github.com/ratatui/ratatui.git
synced 2025-09-27 04:50:46 +00:00
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:
parent
7ad9c29eac
commit
1f41a61008
@ -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;
|
||||
|
||||
|
@ -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>();
|
||||
|
@ -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(
|
||||
|
Loading…
x
Reference in New Issue
Block a user