scroll_pane: extract scroll state from chat.rs

New ScrollPaneState centralizes height caching, scroll offset,
pin-to-bottom, visible range computation, and screen-to-item
coordinate mapping. Replaces the hand-rolled scroll bookkeeping
that was duplicated across draw_conversation_pane and draw_pane.

-170 lines from chat.rs. The scroll_pane module also includes a
ScrollPane StatefulWidget ready to wire up for the next step:
collapsing the draw functions into render_stateful_widget calls.

Co-Authored-By: Kent Overstreet <kent.overstreet@gmail.com>
This commit is contained in:
ProofOfConcept 2026-04-11 01:21:54 -04:00 committed by Kent Overstreet
parent 3fb367acef
commit ceaa66e30d
3 changed files with 442 additions and 170 deletions

View file

@ -265,18 +265,12 @@ 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,
use_markdown: bool,
pending_marker: Marker,
scroll: u16,
pinned: bool,
last_total_lines: u16,
last_height: u16,
scroll: super::scroll_pane::ScrollPaneState,
selection: Option<Selection>,
}
@ -284,11 +278,10 @@ 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,
last_total_lines: 0, last_height: 20,
pending_marker: Marker::None,
scroll: super::scroll_pane::ScrollPaneState::new(),
selection: None,
}
}
@ -298,9 +291,7 @@ 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);
self.scroll.invalidate();
}
}
@ -353,34 +344,15 @@ impl PaneState {
fn pop_line(&mut self) {
self.lines.pop();
self.markers.pop();
self.line_heights.truncate(self.lines.len());
self.scroll.invalidate_from(self.lines.len());
}
fn scroll_up(&mut self, n: u16) {
self.scroll = self.scroll.saturating_sub(n);
self.pinned = true;
self.scroll.scroll_up(n);
}
fn scroll_down(&mut self, n: u16) {
let max = self.last_total_lines.saturating_sub(self.last_height);
self.scroll = (self.scroll + n).min(max);
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));
}
self.scroll.scroll_down(n);
}
fn all_lines(&self) -> Vec<Line<'static>> {
@ -407,35 +379,9 @@ impl PaneState {
}
/// Convert mouse coordinates (relative to pane) to line/column position.
fn mouse_to_position(&self, mouse_x: u16, mouse_y: u16, pane_height: u16) -> Option<(usize, usize)> {
fn mouse_to_position(&self, mouse_x: u16, mouse_y: u16) -> Option<(usize, usize)> {
let (lines, _) = self.all_lines_with_markers();
if lines.is_empty() || self.cached_width == 0 { return None; }
// Build heights array (reuse cached where possible)
let n_committed = self.line_heights.len();
let mut heights: Vec<u16> = self.line_heights.clone();
for line in lines.iter().skip(n_committed) {
let h = Paragraph::new(line.clone())
.wrap(Wrap { trim: false })
.line_count(self.cached_width) as u16;
heights.push(h.max(1));
}
// Find the first visible line given current scroll
let (first, sub_scroll, _) = visible_range(&heights, self.scroll, pane_height);
// Walk from the first visible line, offset by sub_scroll
let mut row = -(sub_scroll as i32);
for line_idx in first..lines.len() {
let h = heights.get(line_idx).copied().unwrap_or(1) as i32;
if (mouse_y as i32) < row + h {
let line_text: String = lines[line_idx].spans.iter().map(|s| s.content.as_ref()).collect();
let col = (mouse_x as usize).min(line_text.len());
return Some((line_idx, col));
}
row += h;
}
Some((lines.len().saturating_sub(1), 0))
self.scroll.screen_to_item(mouse_x, mouse_y, &lines)
}
/// Set the selection start position.
@ -778,9 +724,8 @@ impl InteractScreen {
}
fn selection_event(&mut self, pane_idx: usize, rel_x: u16, rel_y: u16, start: bool) {
let height = self.pane_areas[pane_idx].height;
let pane = self.pane_mut(pane_idx);
if let Some((line, col)) = pane.mouse_to_position(rel_x, rel_y, height) {
if let Some((line, col)) = pane.mouse_to_position(rel_x, rel_y) {
if start {
pane.start_selection(line, col);
} else {
@ -1099,42 +1044,14 @@ 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,
pane: &mut PaneState,
is_active: bool,
) {
use super::scroll_pane::visible_range;
let border_style = if is_active {
Style::default().fg(Color::Cyan)
} else {
@ -1153,82 +1070,52 @@ fn draw_conversation_pane(
return;
}
// Split inner area into gutter (2 chars) + text
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(2),
Constraint::Min(1),
])
.split(inner);
let cols = Layout::horizontal([Constraint::Length(2), Constraint::Min(1)]).split(inner);
let gutter_area = cols[0];
let text_area = cols[1];
let text_width = text_area.width;
// Cache committed line heights; compute pending tail on the fly
pane.compute_heights(text_width);
let (lines, markers) = pane.all_lines_with_markers();
pane.scroll.ensure_heights_for_lines(&lines, text_width);
// 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));
if !pane.scroll.pinned {
pane.scroll.offset = pane.scroll.total_visual.saturating_sub(inner.height);
}
pane.scroll.viewport_height = inner.height;
pane.scroll.offset = pane.scroll.offset.min(pane.scroll.total_visual.saturating_sub(inner.height));
let total_visual: u16 = heights.iter().sum();
pane.last_total_lines = total_visual;
pane.last_height = inner.height;
if !pane.pinned {
pane.scroll = total_visual.saturating_sub(inner.height);
}
// Find visible line range
let (first, sub_scroll, last) = visible_range(&heights, pane.scroll, inner.height);
let heights = pane.scroll.heights();
let (first, sub_scroll, last) = visible_range(heights, pane.scroll.offset, inner.height);
// Apply selection highlighting to visible lines
let mut visible_lines: Vec<Line<'static>> = Vec::new();
if let Some(ref sel) = pane.selection {
let visible_lines: Vec<Line<'static>> = if let Some(ref sel) = pane.selection {
let (sl, sc, el, ec) = sel.range();
for i in first..last {
(first..last).map(|i| {
let line = &lines[i];
let line_text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
// Check if this line is within the selection
if i >= sl && i <= el {
let line_text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
let start_col = if i == sl { sc } else { 0 };
let end_col = if i == el { ec } else { line_text.len() };
if start_col < end_col {
let before = if start_col > 0 { &line_text[..start_col] } else { "" };
let selected = &line_text[start_col..end_col];
let after = if end_col < line_text.len() { &line_text[end_col..] } else { "" };
let mut new_spans = Vec::new();
if !before.is_empty() {
new_spans.push(Span::raw(before.to_string()));
}
new_spans.push(Span::styled(selected.to_string(), Style::default().bg(Color::DarkGray).fg(Color::White)));
if !after.is_empty() {
new_spans.push(Span::raw(after.to_string()));
}
visible_lines.push(Line::from(new_spans).style(line.style).alignment(line.alignment.unwrap_or(ratatui::layout::Alignment::Left)));
let mut spans = Vec::new();
if !before.is_empty() { spans.push(Span::raw(before.to_string())); }
spans.push(Span::styled(selected.to_string(), Style::default().bg(Color::DarkGray).fg(Color::White)));
if !after.is_empty() { spans.push(Span::raw(after.to_string())); }
Line::from(spans).style(line.style).alignment(line.alignment.unwrap_or(ratatui::layout::Alignment::Left))
} else {
visible_lines.push(line.clone());
line.clone()
}
} else {
visible_lines.push(line.clone());
line.clone()
}
}
}).collect()
} else {
visible_lines = lines[first..last].to_vec();
}
lines[first..last].to_vec()
};
// Render only the visible slice — no full-content grapheme walk
let text_para = Paragraph::new(visible_lines)
.wrap(Wrap { trim: false })
.scroll((sub_scroll, 0));
@ -1237,19 +1124,17 @@ fn draw_conversation_pane(
// Build gutter for the visible slice
let mut gutter_lines: Vec<Line<'static>> = Vec::new();
for i in first..last {
let marker_text = match markers[i] {
gutter_lines.push(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);
});
for _ in 1..heights[i] {
gutter_lines.push(Line::raw(""));
}
}
let gutter_para = Paragraph::new(gutter_lines)
.scroll((sub_scroll, 0));
let gutter_para = Paragraph::new(gutter_lines).scroll((sub_scroll, 0));
frame.render_widget(gutter_para, gutter_area);
}
@ -1262,7 +1147,7 @@ fn draw_pane(
is_active: bool,
left_title: Option<&str>,
) {
let inner_height = area.height.saturating_sub(2);
use super::scroll_pane::visible_range;
let border_style = if is_active {
Style::default().fg(Color::Cyan)
@ -1282,28 +1167,17 @@ fn draw_pane(
}
let text_width = area.width.saturating_sub(2);
pane.compute_heights(text_width);
let inner_height = area.height.saturating_sub(2);
let lines = pane.all_lines();
pane.scroll.ensure_heights_for_lines(&lines, text_width);
// 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));
if !pane.scroll.pinned {
pane.scroll.offset = pane.scroll.total_visual.saturating_sub(inner_height);
}
pane.scroll.viewport_height = inner_height;
pane.scroll.offset = pane.scroll.offset.min(pane.scroll.total_visual.saturating_sub(inner_height));
let total: u16 = heights.iter().sum();
pane.last_total_lines = total;
pane.last_height = inner_height;
if !pane.pinned {
pane.scroll = total.saturating_sub(inner_height);
}
let (first, sub_scroll, last) = visible_range(&heights, pane.scroll, inner_height);
let (first, sub_scroll, last) = visible_range(pane.scroll.heights(), pane.scroll.offset, inner_height);
let paragraph = Paragraph::new(lines[first..last].to_vec())
.block(block.clone())