forked from kent/consciousness
350 lines
11 KiB
Rust
350 lines
11 KiB
Rust
//! 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<'a> ScrollItem for Line<'a> {
|
|
fn content(&self) -> Text<'_> {
|
|
Text::from(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<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
|
|
}
|
|
}
|
|
|
|
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<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
|
|
});
|
|
}
|
|
|
|
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));
|
|
}
|
|
}
|