Render only visible lines in conversation and tools panes
ratatui's Paragraph with Wrap does full unicode grapheme segmentation on render — including for scrolled-off content. Cache per-line wrapped heights on PaneState (recomputed only on width change or new lines), then slice to only the visible lines before handing to ratatui. Eliminates O(total_lines) grapheme work per frame, replacing it with O(viewport_height) — ~30 lines instead of potentially thousands. Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
parent
f4664ca06f
commit
6e9ad04bfc
1 changed files with 97 additions and 39 deletions
136
src/user/chat.rs
136
src/user/chat.rs
|
|
@ -247,6 +247,9 @@ fn parse_markdown(md: &str) -> Vec<Line<'static>> {
|
|||
struct PaneState {
|
||||
lines: Vec<Line<'static>>,
|
||||
markers: Vec<Marker>,
|
||||
/// Cached wrapped height for each line, valid when cached_width matches.
|
||||
line_heights: Vec<u16>,
|
||||
cached_width: u16,
|
||||
current_line: String,
|
||||
current_color: Color,
|
||||
md_buffer: String,
|
||||
|
|
@ -262,6 +265,7 @@ impl PaneState {
|
|||
fn new(use_markdown: bool) -> Self {
|
||||
Self {
|
||||
lines: Vec::new(), markers: Vec::new(),
|
||||
line_heights: Vec::new(), cached_width: 0,
|
||||
current_line: String::new(), current_color: Color::Reset,
|
||||
md_buffer: String::new(), use_markdown,
|
||||
pending_marker: Marker::None, scroll: 0, pinned: false,
|
||||
|
|
@ -274,6 +278,8 @@ impl PaneState {
|
|||
let excess = self.lines.len() - MAX_PANE_LINES;
|
||||
self.lines.drain(..excess);
|
||||
self.markers.drain(..excess);
|
||||
let drain = excess.min(self.line_heights.len());
|
||||
self.line_heights.drain(..drain);
|
||||
self.scroll = self.scroll.saturating_sub(excess as u16);
|
||||
}
|
||||
}
|
||||
|
|
@ -309,6 +315,7 @@ impl PaneState {
|
|||
fn pop_line(&mut self) {
|
||||
self.lines.pop();
|
||||
self.markers.pop();
|
||||
self.line_heights.truncate(self.lines.len());
|
||||
}
|
||||
|
||||
fn scroll_up(&mut self, n: u16) {
|
||||
|
|
@ -322,6 +329,22 @@ impl PaneState {
|
|||
if self.scroll >= max { self.pinned = false; }
|
||||
}
|
||||
|
||||
/// Ensure cached line heights cover all committed lines at the given width.
|
||||
fn compute_heights(&mut self, width: u16) {
|
||||
if width != self.cached_width {
|
||||
self.line_heights.clear();
|
||||
self.cached_width = width;
|
||||
}
|
||||
self.line_heights.truncate(self.lines.len());
|
||||
while self.line_heights.len() < self.lines.len() {
|
||||
let i = self.line_heights.len();
|
||||
let h = Paragraph::new(self.lines[i].clone())
|
||||
.wrap(Wrap { trim: false })
|
||||
.line_count(width) as u16;
|
||||
self.line_heights.push(h.max(1));
|
||||
}
|
||||
}
|
||||
|
||||
fn all_lines(&self) -> Vec<Line<'static>> {
|
||||
let (lines, _) = self.all_lines_with_markers();
|
||||
lines
|
||||
|
|
@ -848,6 +871,36 @@ impl ScreenView for InteractScreen {
|
|||
|
||||
/// Draw the conversation pane with a two-column layout: marker gutter + text.
|
||||
/// The gutter shows a marker at turn boundaries, aligned with the input gutter.
|
||||
/// Given per-line heights, a scroll offset, and viewport height,
|
||||
/// return (first_line, sub_scroll_within_first, last_line_exclusive).
|
||||
fn visible_range(heights: &[u16], scroll: u16, viewport: u16) -> (usize, u16, usize) {
|
||||
let mut row = 0u16;
|
||||
let mut first = 0;
|
||||
let mut row_at_first = 0u16;
|
||||
for (i, &h) in heights.iter().enumerate() {
|
||||
if row + h > scroll {
|
||||
first = i;
|
||||
row_at_first = row;
|
||||
break;
|
||||
}
|
||||
row += h;
|
||||
if i == heights.len() - 1 {
|
||||
first = heights.len();
|
||||
row_at_first = row;
|
||||
}
|
||||
}
|
||||
let sub_scroll = scroll.saturating_sub(row_at_first);
|
||||
|
||||
let mut last = first;
|
||||
let mut visible = 0u16;
|
||||
for i in first..heights.len() {
|
||||
visible += heights[i];
|
||||
last = i + 1;
|
||||
if visible >= viewport + sub_scroll { break; }
|
||||
}
|
||||
(first, sub_scroll, last)
|
||||
}
|
||||
|
||||
fn draw_conversation_pane(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
|
|
@ -884,21 +937,23 @@ fn draw_conversation_pane(
|
|||
let gutter_area = cols[0];
|
||||
let text_area = cols[1];
|
||||
|
||||
// Get lines and markers
|
||||
let (lines, markers) = pane.all_lines_with_markers();
|
||||
let text_width = text_area.width;
|
||||
|
||||
// Compute visual row for each logical line (accounting for word wrap)
|
||||
let mut visual_rows: Vec<u16> = Vec::with_capacity(lines.len());
|
||||
let mut cumulative: u16 = 0;
|
||||
for line in &lines {
|
||||
visual_rows.push(cumulative);
|
||||
let para = Paragraph::new(line.clone()).wrap(Wrap { trim: false });
|
||||
let height = para.line_count(text_width) as u16;
|
||||
cumulative += height.max(1);
|
||||
}
|
||||
let total_visual = cumulative;
|
||||
// Cache committed line heights; compute pending tail on the fly
|
||||
pane.compute_heights(text_width);
|
||||
let (lines, markers) = pane.all_lines_with_markers();
|
||||
|
||||
// Build heights: cached for committed lines, computed for pending tail
|
||||
let n_committed = pane.line_heights.len();
|
||||
let mut heights: Vec<u16> = pane.line_heights.clone();
|
||||
for line in lines.iter().skip(n_committed) {
|
||||
let h = Paragraph::new(line.clone())
|
||||
.wrap(Wrap { trim: false })
|
||||
.line_count(text_width) as u16;
|
||||
heights.push(h.max(1));
|
||||
}
|
||||
|
||||
let total_visual: u16 = heights.iter().sum();
|
||||
pane.last_total_lines = total_visual;
|
||||
pane.last_height = inner.height;
|
||||
|
||||
|
|
@ -906,41 +961,31 @@ fn draw_conversation_pane(
|
|||
pane.scroll = total_visual.saturating_sub(inner.height);
|
||||
}
|
||||
|
||||
// Render text column
|
||||
let text_para = Paragraph::new(lines.clone())
|
||||
// Find visible line range
|
||||
let (first, sub_scroll, last) = visible_range(&heights, pane.scroll, inner.height);
|
||||
|
||||
// Render only the visible slice — no full-content grapheme walk
|
||||
let text_para = Paragraph::new(lines[first..last].to_vec())
|
||||
.wrap(Wrap { trim: false })
|
||||
.scroll((pane.scroll, 0));
|
||||
.scroll((sub_scroll, 0));
|
||||
frame.render_widget(text_para, text_area);
|
||||
|
||||
// Render gutter markers at the correct visual rows
|
||||
// Build gutter for the visible slice
|
||||
let mut gutter_lines: Vec<Line<'static>> = Vec::new();
|
||||
let mut next_visual = 0u16;
|
||||
for (i, &marker) in markers.iter().enumerate() {
|
||||
let row = visual_rows[i];
|
||||
// Fill blank lines up to this marker's row
|
||||
while next_visual < row {
|
||||
gutter_lines.push(Line::raw(""));
|
||||
next_visual += 1;
|
||||
}
|
||||
let marker_text = match marker {
|
||||
for i in first..last {
|
||||
let marker_text = match markers[i] {
|
||||
Marker::User => Line::styled("● ", Style::default().fg(Color::Cyan)),
|
||||
Marker::Assistant => Line::styled("● ", Style::default().fg(Color::Magenta)),
|
||||
Marker::None => Line::raw(""),
|
||||
};
|
||||
gutter_lines.push(marker_text);
|
||||
next_visual = row + 1;
|
||||
|
||||
// Fill remaining visual lines for this logical line (wrap continuation)
|
||||
let para = Paragraph::new(lines[i].clone()).wrap(Wrap { trim: false });
|
||||
let height = para.line_count(text_width) as u16;
|
||||
for _ in 1..height.max(1) {
|
||||
for _ in 1..heights[i] {
|
||||
gutter_lines.push(Line::raw(""));
|
||||
next_visual += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let gutter_para = Paragraph::new(gutter_lines)
|
||||
.scroll((pane.scroll, 0));
|
||||
.scroll((sub_scroll, 0));
|
||||
frame.render_widget(gutter_para, gutter_area);
|
||||
}
|
||||
|
||||
|
|
@ -972,13 +1017,21 @@ fn draw_pane(
|
|||
block = block.title(format!(" {} ", title));
|
||||
}
|
||||
|
||||
let text_width = area.width.saturating_sub(2);
|
||||
pane.compute_heights(text_width);
|
||||
let lines = pane.all_lines();
|
||||
let paragraph = Paragraph::new(lines)
|
||||
.block(block.clone())
|
||||
.wrap(Wrap { trim: false });
|
||||
|
||||
// Let ratatui tell us the total visual lines — no homegrown wrapping math.
|
||||
let total = paragraph.line_count(area.width.saturating_sub(2)) as u16;
|
||||
// Build heights: cached for committed, computed for pending tail
|
||||
let n_committed = pane.line_heights.len();
|
||||
let mut heights: Vec<u16> = pane.line_heights.clone();
|
||||
for line in lines.iter().skip(n_committed) {
|
||||
let h = Paragraph::new(line.clone())
|
||||
.wrap(Wrap { trim: false })
|
||||
.line_count(text_width) as u16;
|
||||
heights.push(h.max(1));
|
||||
}
|
||||
|
||||
let total: u16 = heights.iter().sum();
|
||||
pane.last_total_lines = total;
|
||||
pane.last_height = inner_height;
|
||||
|
||||
|
|
@ -986,6 +1039,11 @@ fn draw_pane(
|
|||
pane.scroll = total.saturating_sub(inner_height);
|
||||
}
|
||||
|
||||
let paragraph = paragraph.scroll((pane.scroll, 0));
|
||||
let (first, sub_scroll, last) = visible_range(&heights, pane.scroll, inner_height);
|
||||
|
||||
let paragraph = Paragraph::new(lines[first..last].to_vec())
|
||||
.block(block.clone())
|
||||
.wrap(Wrap { trim: false })
|
||||
.scroll((sub_scroll, 0));
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue