diff --git a/src/user/chat.rs b/src/user/chat.rs index 098260a..97a574d 100644 --- a/src/user/chat.rs +++ b/src/user/chat.rs @@ -265,18 +265,12 @@ fn parse_markdown(md: &str) -> Vec> { struct PaneState { lines: Vec>, markers: Vec, - /// Cached wrapped height for each line, valid when cached_width matches. - line_heights: Vec, - cached_width: u16, current_line: String, current_color: Color, md_buffer: String, use_markdown: bool, pending_marker: Marker, - scroll: u16, - pinned: bool, - last_total_lines: u16, - last_height: u16, + scroll: super::scroll_pane::ScrollPaneState, selection: Option, } @@ -284,11 +278,10 @@ impl PaneState { fn new(use_markdown: bool) -> Self { Self { lines: Vec::new(), markers: Vec::new(), - line_heights: Vec::new(), cached_width: 0, current_line: String::new(), current_color: Color::Reset, md_buffer: String::new(), use_markdown, - pending_marker: Marker::None, scroll: 0, pinned: false, - last_total_lines: 0, last_height: 20, + pending_marker: Marker::None, + scroll: super::scroll_pane::ScrollPaneState::new(), selection: None, } } @@ -298,9 +291,7 @@ impl PaneState { let excess = self.lines.len() - MAX_PANE_LINES; self.lines.drain(..excess); self.markers.drain(..excess); - let drain = excess.min(self.line_heights.len()); - self.line_heights.drain(..drain); - self.scroll = self.scroll.saturating_sub(excess as u16); + self.scroll.invalidate(); } } @@ -353,34 +344,15 @@ impl PaneState { fn pop_line(&mut self) { self.lines.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) { - self.scroll = self.scroll.saturating_sub(n); - self.pinned = true; + self.scroll.scroll_up(n); } fn scroll_down(&mut self, n: u16) { - let max = self.last_total_lines.saturating_sub(self.last_height); - 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)); - } + self.scroll.scroll_down(n); } fn all_lines(&self) -> Vec> { @@ -407,35 +379,9 @@ impl PaneState { } /// 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(); - if lines.is_empty() || self.cached_width == 0 { return None; } - - // Build heights array (reuse cached where possible) - let n_committed = self.line_heights.len(); - let mut heights: Vec = 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)) + self.scroll.screen_to_item(mouse_x, mouse_y, &lines) } /// 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) { - let height = self.pane_areas[pane_idx].height; 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 { pane.start_selection(line, col); } else { @@ -1099,42 +1044,14 @@ impl ScreenView for InteractScreen { /// 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. -/// 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( frame: &mut Frame, area: Rect, pane: &mut PaneState, is_active: bool, ) { + use super::scroll_pane::visible_range; + let border_style = if is_active { Style::default().fg(Color::Cyan) } else { @@ -1153,82 +1070,52 @@ fn draw_conversation_pane( return; } - // Split inner area into gutter (2 chars) + text - let cols = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Length(2), - Constraint::Min(1), - ]) - .split(inner); - + let cols = Layout::horizontal([Constraint::Length(2), Constraint::Min(1)]).split(inner); let gutter_area = cols[0]; let text_area = cols[1]; - 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(); + pane.scroll.ensure_heights_for_lines(&lines, text_width); - // Build heights: cached for committed lines, computed for pending tail - let n_committed = pane.line_heights.len(); - let mut heights: Vec = 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)); + if !pane.scroll.pinned { + pane.scroll.offset = pane.scroll.total_visual.saturating_sub(inner.height); } + 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(); - pane.last_total_lines = total_visual; - 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); + let heights = pane.scroll.heights(); + let (first, sub_scroll, last) = visible_range(heights, pane.scroll.offset, inner.height); // Apply selection highlighting to visible lines - let mut visible_lines: Vec> = Vec::new(); - if let Some(ref sel) = pane.selection { + let visible_lines: Vec> = if let Some(ref sel) = pane.selection { let (sl, sc, el, ec) = sel.range(); - for i in first..last { + (first..last).map(|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 { + let line_text: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); let start_col = if i == sl { sc } else { 0 }; let end_col = if i == el { ec } else { line_text.len() }; if start_col < end_col { let before = if start_col > 0 { &line_text[..start_col] } else { "" }; let selected = &line_text[start_col..end_col]; let after = if end_col < line_text.len() { &line_text[end_col..] } else { "" }; - let mut new_spans = Vec::new(); - if !before.is_empty() { - new_spans.push(Span::raw(before.to_string())); - } - new_spans.push(Span::styled(selected.to_string(), Style::default().bg(Color::DarkGray).fg(Color::White))); - 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))); + let mut spans = Vec::new(); + if !before.is_empty() { 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())); } + Line::from(spans).style(line.style).alignment(line.alignment.unwrap_or(ratatui::layout::Alignment::Left)) } else { - visible_lines.push(line.clone()); + line.clone() } } else { - visible_lines.push(line.clone()); + line.clone() } - } + }).collect() } 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) .wrap(Wrap { trim: false }) .scroll((sub_scroll, 0)); @@ -1237,19 +1124,17 @@ fn draw_conversation_pane( // Build gutter for the visible slice let mut gutter_lines: Vec> = Vec::new(); 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::Assistant => Line::styled("● ", Style::default().fg(Color::Magenta)), Marker::None => Line::raw(""), - }; - gutter_lines.push(marker_text); + }); for _ in 1..heights[i] { gutter_lines.push(Line::raw("")); } } - let gutter_para = Paragraph::new(gutter_lines) - .scroll((sub_scroll, 0)); + let gutter_para = Paragraph::new(gutter_lines).scroll((sub_scroll, 0)); frame.render_widget(gutter_para, gutter_area); } @@ -1262,7 +1147,7 @@ fn draw_pane( is_active: bool, left_title: Option<&str>, ) { - let inner_height = area.height.saturating_sub(2); + use super::scroll_pane::visible_range; let border_style = if is_active { Style::default().fg(Color::Cyan) @@ -1282,28 +1167,17 @@ fn draw_pane( } 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(); + pane.scroll.ensure_heights_for_lines(&lines, text_width); - // Build heights: cached for committed, computed for pending tail - let n_committed = pane.line_heights.len(); - let mut heights: Vec = 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)); + if !pane.scroll.pinned { + pane.scroll.offset = pane.scroll.total_visual.saturating_sub(inner_height); } + 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(); - 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 (first, sub_scroll, last) = visible_range(pane.scroll.heights(), pane.scroll.offset, inner_height); let paragraph = Paragraph::new(lines[first..last].to_vec()) .block(block.clone()) diff --git a/src/user/mod.rs b/src/user/mod.rs index ec35423..28a2df9 100644 --- a/src/user/mod.rs +++ b/src/user/mod.rs @@ -5,6 +5,7 @@ pub(crate) mod chat; mod context; +pub(crate) mod scroll_pane; mod subconscious; mod unconscious; mod thalamus; diff --git a/src/user/scroll_pane.rs b/src/user/scroll_pane.rs new file mode 100644 index 0000000..150ca70 --- /dev/null +++ b/src/user/scroll_pane.rs @@ -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> { + 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() + } +} + +/// 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>, +} + +impl<'a> ScrollItem for BorrowedItem<'a> { + fn content(&self) -> Text<'_> { + Text::from(self.line.clone()) + } + + fn gutter(&self) -> Option> { + 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, + /// 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(&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>, + 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)); + } +}