consciousness/src/user/scroll_pane.rs

351 lines
11 KiB
Rust
Raw Normal View History

//! 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));
}
}