Paragraph with Wrap panics with "index outside of buffer" on double-width graphemes
Description
Paragraph::wrap can emit a wrapped line wider than the area it was given, and
render_line writes it without bounds-checking x. The result is a panic:
index outside of buffer: the area is Rect { x: 0, y: 0, width: 2, height: 11 } but index is (2, 1)
The widget is rendered entirely inside the frame, so this is distinct from #1667, and it is a horizontal overrun rather than the vertical one in #1375.
Two inputs reach it, both involving grapheme widths that do not divide evenly into the remaining space on a row:
- A double-width glyph inside a word too long for a two-cell area.
"ab"is enough. - A leading combining mark — a grapheme cluster with no base character. This one is not confined to narrow areas; the example below is 8 cells wide, and with a mixed-width alphabet I have seen it at widths 3, 4, 5, 6, 8 and 10.
In both cases the panic index is (width, 1) — the second wrapped line. That
detail matters for anyone else hitting this: a one-row area never reaches the
faulty row, so the crash looks intermittent and height-dependent.
To Reproduce
ratatui = "0.30.2", no other dependencies:
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::widgets::{Paragraph, Wrap};
fn draw(text: &str, width: u16, height: u16) {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| {
frame.render_widget(
Paragraph::new(text).wrap(Wrap { trim: false }),
frame.area(),
);
})
.unwrap();
}
#[test]
fn wide_glyph_in_a_two_cell_area() {
draw("a\\\u{1F31E}b", 2, 11); // panics: index is (2, 1)
}
#[test]
fn leading_combining_mark() {
draw("\\\u{0301} \\\u{1F31E}\\\u{65E5}\\\u{65E5}\\\u{65E5}\\\u{1F31E}\\\u{1F31E}a", 8, 11); // panics: index is (8, 1)
}
// Controls, both of which pass — the same text without the leading mark, and
// a plain ASCII word at width 2.
#[test]
fn control_without_the_leading_mark() {
draw(" \\\u{1F31E}\\\u{65E5}\\\u{65E5}\\\u{65E5}\\\u{1F31E}\\\u{1F31E}a", 8, 11);
}
#[test]
fn control_ascii_at_width_two() {
draw("abc", 2, 11);
}
Wrap { trim: true } behaves the same. Reproduced on macOS with
ratatui 0.30.2 / ratatui-widgets 0.3.2 / ratatui-core 0.1.2.
Expected behavior
Wrapping text should not panic. A grapheme that cannot fit the remaining cells should move to the next row, and one wider than the whole row should either overrun by itself or be dropped — but the buffer write should not go out of bounds either way.
Notes towards a fix
render_line (ratatui-widgets/src/paragraph.rs) advances x by each
grapheme's cell_width() and indexes the buffer unconditionally:
let position = Position::new(area.left() + x, area.top() + y);
buf[position].set_symbol(symbol).set_style(*style);
x += width;
It relies on WordWrapper never yielding a line wider than area.width, and
that invariant is what breaks. So there are arguably two changes: the accounting
in WordWrapper for graphemes that straddle the row boundary, and a defensive
if x + width > area.width { break } in render_line — every other widget
clamps, and a wrapping bug should be a misdraw rather than a crash.
Happy to open a PR if you'd like a particular one of those.
Environment
- ratatui 0.30.2 (ratatui-widgets 0.3.2, ratatui-core 0.1.2)
- rustc stable, macOS (backend-independent — reproduced with
TestBackend)
Found while hardening mirador against untrusted input: a note containing an emoji crashed the dashboard.
Source: ratatui/ratatui