//! 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> { None } } // Blanket impls for common types impl ScrollItem for Line<'static> { fn content(&self) -> Text<'_> { Text::from(self.clone()) } } impl ScrollItem for Vec> { fn content(&self) -> Text<'_> { Text::from(self.clone()) } } impl ScrollItem for Text<'static> { fn content(&self) -> Text<'_> { self.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, /// 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 } } 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); } /// 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(&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 }); } 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>, 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 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> = Vec::new(); let mut gutter_lines: Vec> = 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 = vec![]; assert_eq!(visible_range(&heights, 0, 10), (0, 0, 0)); } }