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:
parent
3fb367acef
commit
ceaa66e30d
3 changed files with 442 additions and 170 deletions
397
src/user/scroll_pane.rs
Normal file
397
src/user/scroll_pane.rs
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
//! ScrollPane — a generic scrollable widget with gutter support.
|
||||
//!
|
||||
//! Renders only the visible portion of a list of items, caching
|
||||
//! wrapped heights for performance. Handles scroll offset,
|
||||
//! pin-to-bottom, scrollbar, and an optional gutter column.
|
||||
|
||||
use ratatui::prelude::*;
|
||||
use ratatui::widgets::{Block, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap};
|
||||
|
||||
// ── Trait for scrollable items ─────────────────────────────────
|
||||
|
||||
/// Anything that can appear in a ScrollPane.
|
||||
pub trait ScrollItem {
|
||||
/// The content lines for this item.
|
||||
fn content(&self) -> Text<'_>;
|
||||
|
||||
/// Optional gutter annotation (rendered at the first visual line).
|
||||
fn gutter(&self) -> Option<Span<'_>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// Blanket impls for common types
|
||||
|
||||
impl ScrollItem for Line<'static> {
|
||||
fn content(&self) -> Text<'_> {
|
||||
Text::from(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl ScrollItem for Vec<Line<'static>> {
|
||||
fn content(&self) -> Text<'_> {
|
||||
Text::from(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl ScrollItem for Text<'static> {
|
||||
fn content(&self) -> Text<'_> {
|
||||
self.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 ──────────────────────────────────────────────────────
|
||||
|
||||
pub struct ScrollPaneState {
|
||||
/// Scroll offset in visual (wrapped) lines.
|
||||
pub offset: u16,
|
||||
/// When true, auto-scroll to bottom on new content.
|
||||
/// Set to false when user scrolls up.
|
||||
pub pinned: bool,
|
||||
/// Cached wrapped height per item.
|
||||
heights: Vec<u16>,
|
||||
/// Width these heights were computed at.
|
||||
cached_width: u16,
|
||||
/// Total visual lines (sum of heights).
|
||||
pub total_visual: u16,
|
||||
/// Last rendered viewport height.
|
||||
pub viewport_height: u16,
|
||||
}
|
||||
|
||||
impl Default for ScrollPaneState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
offset: 0,
|
||||
pinned: false,
|
||||
heights: Vec::new(),
|
||||
cached_width: 0,
|
||||
total_visual: 0,
|
||||
viewport_height: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ScrollPaneState {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn scroll_up(&mut self, n: u16) {
|
||||
self.offset = self.offset.saturating_sub(n);
|
||||
self.pinned = true; // user is scrolling, pin position
|
||||
}
|
||||
|
||||
pub fn scroll_down(&mut self, n: u16) {
|
||||
let max = self.max_offset();
|
||||
self.offset = (self.offset + n).min(max);
|
||||
if self.offset >= max {
|
||||
self.pinned = false; // back at bottom, unpin
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scroll_to_bottom(&mut self) {
|
||||
self.offset = self.max_offset();
|
||||
self.pinned = false;
|
||||
}
|
||||
|
||||
fn max_offset(&self) -> u16 {
|
||||
self.total_visual.saturating_sub(self.viewport_height)
|
||||
}
|
||||
|
||||
/// Invalidate height cache (e.g. when items change).
|
||||
pub fn invalidate(&mut self) {
|
||||
self.heights.clear();
|
||||
self.cached_width = 0;
|
||||
}
|
||||
|
||||
/// Invalidate heights from index onwards (for append-only patterns).
|
||||
pub fn invalidate_from(&mut self, index: usize) {
|
||||
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
|
||||
/// 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)> {
|
||||
if lines.is_empty() || self.cached_width == 0 {
|
||||
return None;
|
||||
}
|
||||
let (first, sub_scroll, _) = visible_range(&self.heights, self.offset, self.viewport_height);
|
||||
|
||||
let mut row = -(sub_scroll as i32);
|
||||
for line_idx in first..lines.len() {
|
||||
let h = self.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))
|
||||
}
|
||||
|
||||
/// Compute or update cached heights for the given items and width.
|
||||
fn ensure_heights<T: ScrollItem>(&mut self, items: &[T], width: u16, gutter_width: u16) {
|
||||
let text_width = width.saturating_sub(gutter_width);
|
||||
self.ensure_heights_inner(items.len(), text_width, |i| {
|
||||
Paragraph::new(items[i].content())
|
||||
.wrap(Wrap { trim: false })
|
||||
.line_count(text_width) as u16
|
||||
});
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
if text_width == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.cached_width != text_width {
|
||||
self.heights.clear();
|
||||
self.cached_width = text_width;
|
||||
}
|
||||
|
||||
while self.heights.len() < count {
|
||||
let h = height_fn(self.heights.len());
|
||||
self.heights.push(h.max(1));
|
||||
}
|
||||
|
||||
self.heights.truncate(count);
|
||||
self.total_visual = self.heights.iter().sum();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Widget ─────────────────────────────────────────────────────
|
||||
|
||||
pub struct ScrollPane<'a, T> {
|
||||
items: &'a [T],
|
||||
block: Option<Block<'a>>,
|
||||
gutter_width: u16,
|
||||
pin_to_bottom: bool,
|
||||
}
|
||||
|
||||
impl<'a, T: ScrollItem> ScrollPane<'a, T> {
|
||||
pub fn new(items: &'a [T]) -> Self {
|
||||
Self {
|
||||
items,
|
||||
block: None,
|
||||
gutter_width: 0,
|
||||
pin_to_bottom: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn block(mut self, block: Block<'a>) -> Self {
|
||||
self.block = Some(block);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn gutter_width(mut self, width: u16) -> Self {
|
||||
self.gutter_width = width;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn pin_to_bottom(mut self, pin: bool) -> Self {
|
||||
self.pin_to_bottom = pin;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ScrollItem> StatefulWidget for ScrollPane<'_, T> {
|
||||
type State = ScrollPaneState;
|
||||
|
||||
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
||||
// Render block and get inner area
|
||||
let inner = if let Some(ref block) = self.block {
|
||||
let inner = block.inner(area);
|
||||
block.clone().render(area, buf);
|
||||
inner
|
||||
} else {
|
||||
area
|
||||
};
|
||||
|
||||
if inner.width < 2 || inner.height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
state.viewport_height = inner.height;
|
||||
|
||||
// Compute heights
|
||||
state.ensure_heights(self.items, inner.width, self.gutter_width);
|
||||
|
||||
// Auto-scroll to bottom
|
||||
if self.pin_to_bottom && !state.pinned {
|
||||
state.offset = state.max_offset();
|
||||
}
|
||||
|
||||
// Clamp offset
|
||||
state.offset = state.offset.min(state.max_offset());
|
||||
|
||||
// Find visible range
|
||||
let (first, sub_scroll, last) =
|
||||
visible_range(&state.heights, state.offset, inner.height);
|
||||
|
||||
// Split into gutter + text areas
|
||||
let (gutter_area, text_area) = if self.gutter_width > 0 {
|
||||
let cols = Layout::horizontal([
|
||||
Constraint::Length(self.gutter_width),
|
||||
Constraint::Min(1),
|
||||
])
|
||||
.split(inner);
|
||||
(Some(cols[0]), cols[1])
|
||||
} else {
|
||||
(None, inner)
|
||||
};
|
||||
|
||||
// Render visible items
|
||||
let mut content_lines: Vec<Line<'_>> = Vec::new();
|
||||
let mut gutter_lines: Vec<Line<'_>> = Vec::new();
|
||||
|
||||
for i in first..last {
|
||||
let item = &self.items[i];
|
||||
|
||||
for line in item.content().lines {
|
||||
content_lines.push(line);
|
||||
}
|
||||
|
||||
// Gutter: annotation at the first visual line of each
|
||||
// item, blank lines for the rest (including wrapped lines).
|
||||
if self.gutter_width > 0 {
|
||||
let item_height = state.heights[i] as usize;
|
||||
if let Some(g) = item.gutter() {
|
||||
gutter_lines.push(Line::from(g));
|
||||
} else {
|
||||
gutter_lines.push(Line::raw(""));
|
||||
}
|
||||
for _ in 1..item_height {
|
||||
gutter_lines.push(Line::raw(""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render text
|
||||
let text_para = Paragraph::new(content_lines)
|
||||
.wrap(Wrap { trim: false })
|
||||
.scroll((sub_scroll, 0));
|
||||
text_para.render(text_area, buf);
|
||||
|
||||
// Render gutter
|
||||
if let Some(gutter_area) = gutter_area {
|
||||
let gutter_para = Paragraph::new(gutter_lines)
|
||||
.scroll((sub_scroll, 0));
|
||||
gutter_para.render(gutter_area, buf);
|
||||
}
|
||||
|
||||
// Render scrollbar
|
||||
let content_len = state.total_visual as usize;
|
||||
let visible = inner.height as usize;
|
||||
if content_len > visible {
|
||||
let mut sb_state = ScrollbarState::new(content_len)
|
||||
.position(state.offset as usize);
|
||||
Scrollbar::new(ScrollbarOrientation::VerticalRight).render(
|
||||
inner.inner(Margin { vertical: 0, horizontal: 0 }),
|
||||
buf,
|
||||
&mut sb_state,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Visible range computation ──────────────────────────────────
|
||||
|
||||
/// Given per-item wrapped heights, a scroll offset in visual lines,
|
||||
/// and the viewport height, return:
|
||||
/// (first_item, sub_scroll_within_first_item, last_item_exclusive)
|
||||
pub fn visible_range(heights: &[u16], scroll: u16, viewport: u16) -> (usize, u16, usize) {
|
||||
if heights.is_empty() {
|
||||
return (0, 0, 0);
|
||||
}
|
||||
|
||||
// Find first visible item
|
||||
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);
|
||||
|
||||
// Find last visible item
|
||||
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)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn visible_range_basic() {
|
||||
let heights = vec![1, 1, 1, 1, 1];
|
||||
assert_eq!(visible_range(&heights, 0, 3), (0, 0, 3));
|
||||
assert_eq!(visible_range(&heights, 2, 3), (2, 0, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_range_wrapped() {
|
||||
// Item 1 wraps to 3 lines, others are 1 line
|
||||
let heights = vec![1, 3, 1, 1];
|
||||
assert_eq!(visible_range(&heights, 0, 3), (0, 0, 2));
|
||||
assert_eq!(visible_range(&heights, 1, 3), (1, 0, 2));
|
||||
assert_eq!(visible_range(&heights, 2, 3), (1, 1, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_range_empty() {
|
||||
let heights: Vec<u16> = vec![];
|
||||
assert_eq!(visible_range(&heights, 0, 10), (0, 0, 0));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue