chat: use ScrollPane widget for both draw functions

draw_conversation_pane and draw_pane now delegate all scroll
bookkeeping and rendering to the ScrollPane widget. The conversation
pane builds MarkedLine items (line + gutter marker), applies
selection highlighting, and passes them to the widget. The simpler
panes just pass lines directly.

Removed dead code from scroll_pane: BorrowedItem, scroll_to_bottom,
heights(), ensure_heights_for_lines — all superseded by the widget
doing the work internally through the ScrollItem trait.

Co-Authored-By: Kent Overstreet <kent.overstreet@gmail.com>
This commit is contained in:
ProofOfConcept 2026-04-11 01:27:46 -04:00 committed by Kent Overstreet
parent ceaa66e30d
commit 2d6a68048c
2 changed files with 46 additions and 106 deletions

View file

@ -141,6 +141,32 @@ enum Marker {
Assistant, Assistant,
} }
impl Marker {
fn gutter_span(self) -> Option<Span<'static>> {
match self {
Marker::User => Some(Span::styled("", Style::default().fg(Color::Cyan))),
Marker::Assistant => Some(Span::styled("", Style::default().fg(Color::Magenta))),
Marker::None => None,
}
}
}
/// A line paired with a gutter marker, for use with ScrollPane.
struct MarkedLine {
line: Line<'static>,
marker: Marker,
}
impl super::scroll_pane::ScrollItem for MarkedLine {
fn content(&self) -> ratatui::text::Text<'_> {
ratatui::text::Text::from(self.line.clone())
}
fn gutter(&self) -> Option<Span<'_>> {
self.marker.gutter_span()
}
}
#[derive(PartialEq)] #[derive(PartialEq)]
enum PaneTarget { enum PaneTarget {
Conversation, Conversation,
@ -1050,7 +1076,7 @@ fn draw_conversation_pane(
pane: &mut PaneState, pane: &mut PaneState,
is_active: bool, is_active: bool,
) { ) {
use super::scroll_pane::visible_range; use super::scroll_pane::ScrollPane;
let border_style = if is_active { let border_style = if is_active {
Style::default().fg(Color::Cyan) Style::default().fg(Color::Cyan)
@ -1063,36 +1089,13 @@ fn draw_conversation_pane(
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(border_style); .border_style(border_style);
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.width < 5 || inner.height == 0 {
return;
}
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;
let (lines, markers) = pane.all_lines_with_markers(); let (lines, markers) = pane.all_lines_with_markers();
pane.scroll.ensure_heights_for_lines(&lines, text_width);
if !pane.scroll.pinned { // Apply selection highlighting
pane.scroll.offset = pane.scroll.total_visual.saturating_sub(inner.height); let items: Vec<MarkedLine> = if let Some(ref sel) = pane.selection {
}
pane.scroll.viewport_height = inner.height;
pane.scroll.offset = pane.scroll.offset.min(pane.scroll.total_visual.saturating_sub(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 visible_lines: Vec<Line<'static>> = if let Some(ref sel) = pane.selection {
let (sl, sc, el, ec) = sel.range(); let (sl, sc, el, ec) = sel.range();
(first..last).map(|i| { lines.into_iter().zip(markers).enumerate().map(|(i, (line, marker))| {
let line = &lines[i]; let line = if i >= sl && i <= el {
if i >= sl && i <= el {
let line_text: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); let line_text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
let start_col = if i == sl { sc } else { 0 }; let start_col = if i == sl { sc } else { 0 };
let end_col = if i == el { ec } else { line_text.len() }; let end_col = if i == el { ec } else { line_text.len() };
@ -1106,36 +1109,22 @@ fn draw_conversation_pane(
if !after.is_empty() { spans.push(Span::raw(after.to_string())); } 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)) Line::from(spans).style(line.style).alignment(line.alignment.unwrap_or(ratatui::layout::Alignment::Left))
} else { } else {
line.clone() line
} }
} else { } else {
line.clone() line
} };
MarkedLine { line, marker }
}).collect() }).collect()
} else { } else {
lines[first..last].to_vec() lines.into_iter().zip(markers).map(|(line, marker)| MarkedLine { line, marker }).collect()
}; };
let text_para = Paragraph::new(visible_lines) let widget = ScrollPane::new(&items)
.wrap(Wrap { trim: false }) .block(block)
.scroll((sub_scroll, 0)); .gutter_width(2)
frame.render_widget(text_para, text_area); .pin_to_bottom(true);
frame.render_stateful_widget(widget, area, &mut pane.scroll);
// Build gutter for the visible slice
let mut gutter_lines: Vec<Line<'static>> = Vec::new();
for i in first..last {
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(""),
});
for _ in 1..heights[i] {
gutter_lines.push(Line::raw(""));
}
}
let gutter_para = Paragraph::new(gutter_lines).scroll((sub_scroll, 0));
frame.render_widget(gutter_para, gutter_area);
} }
/// Draw a scrollable text pane (free function to avoid borrow issues). /// Draw a scrollable text pane (free function to avoid borrow issues).
@ -1147,7 +1136,7 @@ fn draw_pane(
is_active: bool, is_active: bool,
left_title: Option<&str>, left_title: Option<&str>,
) { ) {
use super::scroll_pane::visible_range; use super::scroll_pane::ScrollPane;
let border_style = if is_active { let border_style = if is_active {
Style::default().fg(Color::Cyan) Style::default().fg(Color::Cyan)
@ -1166,22 +1155,9 @@ fn draw_pane(
block = block.title(format!(" {} ", title)); block = block.title(format!(" {} ", title));
} }
let text_width = area.width.saturating_sub(2);
let inner_height = area.height.saturating_sub(2);
let lines = pane.all_lines(); let lines = pane.all_lines();
pane.scroll.ensure_heights_for_lines(&lines, text_width); let widget = ScrollPane::new(&lines)
.block(block)
if !pane.scroll.pinned { .pin_to_bottom(true);
pane.scroll.offset = pane.scroll.total_visual.saturating_sub(inner_height); frame.render_stateful_widget(widget, area, &mut pane.scroll);
}
pane.scroll.viewport_height = inner_height;
pane.scroll.offset = pane.scroll.offset.min(pane.scroll.total_visual.saturating_sub(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())
.wrap(Wrap { trim: false })
.scroll((sub_scroll, 0));
frame.render_widget(paragraph, area);
} }

View file

@ -40,23 +40,6 @@ impl ScrollItem for Text<'static> {
} }
} }
/// A borrowed item: a line with an optional gutter span.
/// Useful for rendering from parallel slices (lines + markers).
pub struct BorrowedItem<'a> {
pub line: &'a Line<'a>,
pub gutter_span: Option<Span<'a>>,
}
impl<'a> ScrollItem for BorrowedItem<'a> {
fn content(&self) -> Text<'_> {
Text::from(self.line.clone())
}
fn gutter(&self) -> Option<Span<'_>> {
self.gutter_span.clone()
}
}
// ── State ────────────────────────────────────────────────────── // ── State ──────────────────────────────────────────────────────
pub struct ScrollPaneState { pub struct ScrollPaneState {
@ -106,11 +89,6 @@ impl ScrollPaneState {
} }
} }
pub fn scroll_to_bottom(&mut self) {
self.offset = self.max_offset();
self.pinned = false;
}
fn max_offset(&self) -> u16 { fn max_offset(&self) -> u16 {
self.total_visual.saturating_sub(self.viewport_height) self.total_visual.saturating_sub(self.viewport_height)
} }
@ -126,11 +104,6 @@ impl ScrollPaneState {
self.heights.truncate(index); self.heights.truncate(index);
} }
/// Access cached heights (for mouse coordinate mapping).
pub fn heights(&self) -> &[u16] {
&self.heights
}
/// Convert a screen row (relative to viewport) to an item index and /// Convert a screen row (relative to viewport) to an item index and
/// column, given the content items for text extraction. /// column, given the content items for text extraction.
pub fn screen_to_item(&self, mouse_x: u16, mouse_y: u16, lines: &[Line<'_>]) -> Option<(usize, usize)> { pub fn screen_to_item(&self, mouse_x: u16, mouse_y: u16, lines: &[Line<'_>]) -> Option<(usize, usize)> {
@ -162,15 +135,6 @@ impl ScrollPaneState {
}); });
} }
/// Compute or update cached heights from raw Lines.
pub fn ensure_heights_for_lines(&mut self, lines: &[Line<'_>], text_width: u16) {
self.ensure_heights_inner(lines.len(), text_width, |i| {
Paragraph::new(lines[i].clone())
.wrap(Wrap { trim: false })
.line_count(text_width) as u16
});
}
fn ensure_heights_inner(&mut self, count: usize, text_width: u16, height_fn: impl Fn(usize) -> u16) { fn ensure_heights_inner(&mut self, count: usize, text_width: u16, height_fn: impl Fn(usize) -> u16) {
if text_width == 0 { if text_width == 0 {
return; return;