forked from kent/consciousness
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
214
src/user/chat.rs
214
src/user/chat.rs
|
|
@ -265,18 +265,12 @@ fn parse_markdown(md: &str) -> Vec<Line<'static>> {
|
||||||
struct PaneState {
|
struct PaneState {
|
||||||
lines: Vec<Line<'static>>,
|
lines: Vec<Line<'static>>,
|
||||||
markers: Vec<Marker>,
|
markers: Vec<Marker>,
|
||||||
/// Cached wrapped height for each line, valid when cached_width matches.
|
|
||||||
line_heights: Vec<u16>,
|
|
||||||
cached_width: u16,
|
|
||||||
current_line: String,
|
current_line: String,
|
||||||
current_color: Color,
|
current_color: Color,
|
||||||
md_buffer: String,
|
md_buffer: String,
|
||||||
use_markdown: bool,
|
use_markdown: bool,
|
||||||
pending_marker: Marker,
|
pending_marker: Marker,
|
||||||
scroll: u16,
|
scroll: super::scroll_pane::ScrollPaneState,
|
||||||
pinned: bool,
|
|
||||||
last_total_lines: u16,
|
|
||||||
last_height: u16,
|
|
||||||
selection: Option<Selection>,
|
selection: Option<Selection>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -284,11 +278,10 @@ impl PaneState {
|
||||||
fn new(use_markdown: bool) -> Self {
|
fn new(use_markdown: bool) -> Self {
|
||||||
Self {
|
Self {
|
||||||
lines: Vec::new(), markers: Vec::new(),
|
lines: Vec::new(), markers: Vec::new(),
|
||||||
line_heights: Vec::new(), cached_width: 0,
|
|
||||||
current_line: String::new(), current_color: Color::Reset,
|
current_line: String::new(), current_color: Color::Reset,
|
||||||
md_buffer: String::new(), use_markdown,
|
md_buffer: String::new(), use_markdown,
|
||||||
pending_marker: Marker::None, scroll: 0, pinned: false,
|
pending_marker: Marker::None,
|
||||||
last_total_lines: 0, last_height: 20,
|
scroll: super::scroll_pane::ScrollPaneState::new(),
|
||||||
selection: None,
|
selection: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -298,9 +291,7 @@ impl PaneState {
|
||||||
let excess = self.lines.len() - MAX_PANE_LINES;
|
let excess = self.lines.len() - MAX_PANE_LINES;
|
||||||
self.lines.drain(..excess);
|
self.lines.drain(..excess);
|
||||||
self.markers.drain(..excess);
|
self.markers.drain(..excess);
|
||||||
let drain = excess.min(self.line_heights.len());
|
self.scroll.invalidate();
|
||||||
self.line_heights.drain(..drain);
|
|
||||||
self.scroll = self.scroll.saturating_sub(excess as u16);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -353,34 +344,15 @@ impl PaneState {
|
||||||
fn pop_line(&mut self) {
|
fn pop_line(&mut self) {
|
||||||
self.lines.pop();
|
self.lines.pop();
|
||||||
self.markers.pop();
|
self.markers.pop();
|
||||||
self.line_heights.truncate(self.lines.len());
|
self.scroll.invalidate_from(self.lines.len());
|
||||||
}
|
}
|
||||||
|
|
||||||
fn scroll_up(&mut self, n: u16) {
|
fn scroll_up(&mut self, n: u16) {
|
||||||
self.scroll = self.scroll.saturating_sub(n);
|
self.scroll.scroll_up(n);
|
||||||
self.pinned = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn scroll_down(&mut self, n: u16) {
|
fn scroll_down(&mut self, n: u16) {
|
||||||
let max = self.last_total_lines.saturating_sub(self.last_height);
|
self.scroll.scroll_down(n);
|
||||||
self.scroll = (self.scroll + n).min(max);
|
|
||||||
if self.scroll >= max { self.pinned = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ensure cached line heights cover all committed lines at the given width.
|
|
||||||
fn compute_heights(&mut self, width: u16) {
|
|
||||||
if width != self.cached_width {
|
|
||||||
self.line_heights.clear();
|
|
||||||
self.cached_width = width;
|
|
||||||
}
|
|
||||||
self.line_heights.truncate(self.lines.len());
|
|
||||||
while self.line_heights.len() < self.lines.len() {
|
|
||||||
let i = self.line_heights.len();
|
|
||||||
let h = Paragraph::new(self.lines[i].clone())
|
|
||||||
.wrap(Wrap { trim: false })
|
|
||||||
.line_count(width) as u16;
|
|
||||||
self.line_heights.push(h.max(1));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn all_lines(&self) -> Vec<Line<'static>> {
|
fn all_lines(&self) -> Vec<Line<'static>> {
|
||||||
|
|
@ -407,35 +379,9 @@ impl PaneState {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert mouse coordinates (relative to pane) to line/column position.
|
/// Convert mouse coordinates (relative to pane) to line/column position.
|
||||||
fn mouse_to_position(&self, mouse_x: u16, mouse_y: u16, pane_height: u16) -> Option<(usize, usize)> {
|
fn mouse_to_position(&self, mouse_x: u16, mouse_y: u16) -> Option<(usize, usize)> {
|
||||||
let (lines, _) = self.all_lines_with_markers();
|
let (lines, _) = self.all_lines_with_markers();
|
||||||
if lines.is_empty() || self.cached_width == 0 { return None; }
|
self.scroll.screen_to_item(mouse_x, mouse_y, &lines)
|
||||||
|
|
||||||
// Build heights array (reuse cached where possible)
|
|
||||||
let n_committed = self.line_heights.len();
|
|
||||||
let mut heights: Vec<u16> = self.line_heights.clone();
|
|
||||||
for line in lines.iter().skip(n_committed) {
|
|
||||||
let h = Paragraph::new(line.clone())
|
|
||||||
.wrap(Wrap { trim: false })
|
|
||||||
.line_count(self.cached_width) as u16;
|
|
||||||
heights.push(h.max(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the first visible line given current scroll
|
|
||||||
let (first, sub_scroll, _) = visible_range(&heights, self.scroll, pane_height);
|
|
||||||
|
|
||||||
// Walk from the first visible line, offset by sub_scroll
|
|
||||||
let mut row = -(sub_scroll as i32);
|
|
||||||
for line_idx in first..lines.len() {
|
|
||||||
let h = 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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the selection start position.
|
/// Set the selection start position.
|
||||||
|
|
@ -778,9 +724,8 @@ impl InteractScreen {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn selection_event(&mut self, pane_idx: usize, rel_x: u16, rel_y: u16, start: bool) {
|
fn selection_event(&mut self, pane_idx: usize, rel_x: u16, rel_y: u16, start: bool) {
|
||||||
let height = self.pane_areas[pane_idx].height;
|
|
||||||
let pane = self.pane_mut(pane_idx);
|
let pane = self.pane_mut(pane_idx);
|
||||||
if let Some((line, col)) = pane.mouse_to_position(rel_x, rel_y, height) {
|
if let Some((line, col)) = pane.mouse_to_position(rel_x, rel_y) {
|
||||||
if start {
|
if start {
|
||||||
pane.start_selection(line, col);
|
pane.start_selection(line, col);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1099,42 +1044,14 @@ impl ScreenView for InteractScreen {
|
||||||
|
|
||||||
/// Draw the conversation pane with a two-column layout: marker gutter + text.
|
/// Draw the conversation pane with a two-column layout: marker gutter + text.
|
||||||
/// The gutter shows a marker at turn boundaries, aligned with the input gutter.
|
/// The gutter shows a marker at turn boundaries, aligned with the input gutter.
|
||||||
/// Given per-line heights, a scroll offset, and viewport height,
|
|
||||||
/// return (first_line, sub_scroll_within_first, last_line_exclusive).
|
|
||||||
fn visible_range(heights: &[u16], scroll: u16, viewport: u16) -> (usize, u16, usize) {
|
|
||||||
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);
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_conversation_pane(
|
fn draw_conversation_pane(
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
pane: &mut PaneState,
|
pane: &mut PaneState,
|
||||||
is_active: bool,
|
is_active: bool,
|
||||||
) {
|
) {
|
||||||
|
use super::scroll_pane::visible_range;
|
||||||
|
|
||||||
let border_style = if is_active {
|
let border_style = if is_active {
|
||||||
Style::default().fg(Color::Cyan)
|
Style::default().fg(Color::Cyan)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1153,82 +1070,52 @@ fn draw_conversation_pane(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split inner area into gutter (2 chars) + text
|
let cols = Layout::horizontal([Constraint::Length(2), Constraint::Min(1)]).split(inner);
|
||||||
let cols = Layout::default()
|
|
||||||
.direction(Direction::Horizontal)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Length(2),
|
|
||||||
Constraint::Min(1),
|
|
||||||
])
|
|
||||||
.split(inner);
|
|
||||||
|
|
||||||
let gutter_area = cols[0];
|
let gutter_area = cols[0];
|
||||||
let text_area = cols[1];
|
let text_area = cols[1];
|
||||||
|
|
||||||
let text_width = text_area.width;
|
let text_width = text_area.width;
|
||||||
|
|
||||||
// Cache committed line heights; compute pending tail on the fly
|
|
||||||
pane.compute_heights(text_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);
|
||||||
|
|
||||||
// Build heights: cached for committed lines, computed for pending tail
|
if !pane.scroll.pinned {
|
||||||
let n_committed = pane.line_heights.len();
|
pane.scroll.offset = pane.scroll.total_visual.saturating_sub(inner.height);
|
||||||
let mut heights: Vec<u16> = pane.line_heights.clone();
|
|
||||||
for line in lines.iter().skip(n_committed) {
|
|
||||||
let h = Paragraph::new(line.clone())
|
|
||||||
.wrap(Wrap { trim: false })
|
|
||||||
.line_count(text_width) as u16;
|
|
||||||
heights.push(h.max(1));
|
|
||||||
}
|
}
|
||||||
|
pane.scroll.viewport_height = inner.height;
|
||||||
|
pane.scroll.offset = pane.scroll.offset.min(pane.scroll.total_visual.saturating_sub(inner.height));
|
||||||
|
|
||||||
let total_visual: u16 = heights.iter().sum();
|
let heights = pane.scroll.heights();
|
||||||
pane.last_total_lines = total_visual;
|
let (first, sub_scroll, last) = visible_range(heights, pane.scroll.offset, inner.height);
|
||||||
pane.last_height = inner.height;
|
|
||||||
|
|
||||||
if !pane.pinned {
|
|
||||||
pane.scroll = total_visual.saturating_sub(inner.height);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find visible line range
|
|
||||||
let (first, sub_scroll, last) = visible_range(&heights, pane.scroll, inner.height);
|
|
||||||
|
|
||||||
// Apply selection highlighting to visible lines
|
// Apply selection highlighting to visible lines
|
||||||
let mut visible_lines: Vec<Line<'static>> = Vec::new();
|
let visible_lines: Vec<Line<'static>> = if let Some(ref sel) = pane.selection {
|
||||||
if let Some(ref sel) = pane.selection {
|
|
||||||
let (sl, sc, el, ec) = sel.range();
|
let (sl, sc, el, ec) = sel.range();
|
||||||
for i in first..last {
|
(first..last).map(|i| {
|
||||||
let line = &lines[i];
|
let line = &lines[i];
|
||||||
let line_text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
|
|
||||||
|
|
||||||
// Check if this line is within the selection
|
|
||||||
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 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() };
|
||||||
if start_col < end_col {
|
if start_col < end_col {
|
||||||
let before = if start_col > 0 { &line_text[..start_col] } else { "" };
|
let before = if start_col > 0 { &line_text[..start_col] } else { "" };
|
||||||
let selected = &line_text[start_col..end_col];
|
let selected = &line_text[start_col..end_col];
|
||||||
let after = if end_col < line_text.len() { &line_text[end_col..] } else { "" };
|
let after = if end_col < line_text.len() { &line_text[end_col..] } else { "" };
|
||||||
let mut new_spans = Vec::new();
|
let mut spans = Vec::new();
|
||||||
if !before.is_empty() {
|
if !before.is_empty() { spans.push(Span::raw(before.to_string())); }
|
||||||
new_spans.push(Span::raw(before.to_string()));
|
spans.push(Span::styled(selected.to_string(), Style::default().bg(Color::DarkGray).fg(Color::White)));
|
||||||
}
|
if !after.is_empty() { spans.push(Span::raw(after.to_string())); }
|
||||||
new_spans.push(Span::styled(selected.to_string(), Style::default().bg(Color::DarkGray).fg(Color::White)));
|
Line::from(spans).style(line.style).alignment(line.alignment.unwrap_or(ratatui::layout::Alignment::Left))
|
||||||
if !after.is_empty() {
|
|
||||||
new_spans.push(Span::raw(after.to_string()));
|
|
||||||
}
|
|
||||||
visible_lines.push(Line::from(new_spans).style(line.style).alignment(line.alignment.unwrap_or(ratatui::layout::Alignment::Left)));
|
|
||||||
} else {
|
} else {
|
||||||
visible_lines.push(line.clone());
|
line.clone()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
visible_lines.push(line.clone());
|
line.clone()
|
||||||
}
|
}
|
||||||
}
|
}).collect()
|
||||||
} else {
|
} else {
|
||||||
visible_lines = lines[first..last].to_vec();
|
lines[first..last].to_vec()
|
||||||
}
|
};
|
||||||
|
|
||||||
// Render only the visible slice — no full-content grapheme walk
|
|
||||||
let text_para = Paragraph::new(visible_lines)
|
let text_para = Paragraph::new(visible_lines)
|
||||||
.wrap(Wrap { trim: false })
|
.wrap(Wrap { trim: false })
|
||||||
.scroll((sub_scroll, 0));
|
.scroll((sub_scroll, 0));
|
||||||
|
|
@ -1237,19 +1124,17 @@ fn draw_conversation_pane(
|
||||||
// Build gutter for the visible slice
|
// Build gutter for the visible slice
|
||||||
let mut gutter_lines: Vec<Line<'static>> = Vec::new();
|
let mut gutter_lines: Vec<Line<'static>> = Vec::new();
|
||||||
for i in first..last {
|
for i in first..last {
|
||||||
let marker_text = match markers[i] {
|
gutter_lines.push(match markers[i] {
|
||||||
Marker::User => Line::styled("● ", Style::default().fg(Color::Cyan)),
|
Marker::User => Line::styled("● ", Style::default().fg(Color::Cyan)),
|
||||||
Marker::Assistant => Line::styled("● ", Style::default().fg(Color::Magenta)),
|
Marker::Assistant => Line::styled("● ", Style::default().fg(Color::Magenta)),
|
||||||
Marker::None => Line::raw(""),
|
Marker::None => Line::raw(""),
|
||||||
};
|
});
|
||||||
gutter_lines.push(marker_text);
|
|
||||||
for _ in 1..heights[i] {
|
for _ in 1..heights[i] {
|
||||||
gutter_lines.push(Line::raw(""));
|
gutter_lines.push(Line::raw(""));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let gutter_para = Paragraph::new(gutter_lines)
|
let gutter_para = Paragraph::new(gutter_lines).scroll((sub_scroll, 0));
|
||||||
.scroll((sub_scroll, 0));
|
|
||||||
frame.render_widget(gutter_para, gutter_area);
|
frame.render_widget(gutter_para, gutter_area);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1262,7 +1147,7 @@ fn draw_pane(
|
||||||
is_active: bool,
|
is_active: bool,
|
||||||
left_title: Option<&str>,
|
left_title: Option<&str>,
|
||||||
) {
|
) {
|
||||||
let inner_height = area.height.saturating_sub(2);
|
use super::scroll_pane::visible_range;
|
||||||
|
|
||||||
let border_style = if is_active {
|
let border_style = if is_active {
|
||||||
Style::default().fg(Color::Cyan)
|
Style::default().fg(Color::Cyan)
|
||||||
|
|
@ -1282,28 +1167,17 @@ fn draw_pane(
|
||||||
}
|
}
|
||||||
|
|
||||||
let text_width = area.width.saturating_sub(2);
|
let text_width = area.width.saturating_sub(2);
|
||||||
pane.compute_heights(text_width);
|
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);
|
||||||
|
|
||||||
// Build heights: cached for committed, computed for pending tail
|
if !pane.scroll.pinned {
|
||||||
let n_committed = pane.line_heights.len();
|
pane.scroll.offset = pane.scroll.total_visual.saturating_sub(inner_height);
|
||||||
let mut heights: Vec<u16> = pane.line_heights.clone();
|
|
||||||
for line in lines.iter().skip(n_committed) {
|
|
||||||
let h = Paragraph::new(line.clone())
|
|
||||||
.wrap(Wrap { trim: false })
|
|
||||||
.line_count(text_width) as u16;
|
|
||||||
heights.push(h.max(1));
|
|
||||||
}
|
}
|
||||||
|
pane.scroll.viewport_height = inner_height;
|
||||||
|
pane.scroll.offset = pane.scroll.offset.min(pane.scroll.total_visual.saturating_sub(inner_height));
|
||||||
|
|
||||||
let total: u16 = heights.iter().sum();
|
let (first, sub_scroll, last) = visible_range(pane.scroll.heights(), pane.scroll.offset, inner_height);
|
||||||
pane.last_total_lines = total;
|
|
||||||
pane.last_height = inner_height;
|
|
||||||
|
|
||||||
if !pane.pinned {
|
|
||||||
pane.scroll = total.saturating_sub(inner_height);
|
|
||||||
}
|
|
||||||
|
|
||||||
let (first, sub_scroll, last) = visible_range(&heights, pane.scroll, inner_height);
|
|
||||||
|
|
||||||
let paragraph = Paragraph::new(lines[first..last].to_vec())
|
let paragraph = Paragraph::new(lines[first..last].to_vec())
|
||||||
.block(block.clone())
|
.block(block.clone())
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
pub(crate) mod chat;
|
pub(crate) mod chat;
|
||||||
mod context;
|
mod context;
|
||||||
|
pub(crate) mod scroll_pane;
|
||||||
mod subconscious;
|
mod subconscious;
|
||||||
mod unconscious;
|
mod unconscious;
|
||||||
mod thalamus;
|
mod thalamus;
|
||||||
|
|
|
||||||
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