diff --git a/src/user/chat.rs b/src/user/chat.rs index 97a574d..8756895 100644 --- a/src/user/chat.rs +++ b/src/user/chat.rs @@ -141,6 +141,32 @@ enum Marker { Assistant, } +impl Marker { + fn gutter_span(self) -> Option> { + match self { + Marker::User => Some(Span::styled("● ", Style::default().fg(Color::Cyan))), + Marker::Assistant => Some(Span::styled("● ", Style::default().fg(Color::Magenta))), + Marker::None => None, + } + } +} + +/// A line paired with a gutter marker, for use with ScrollPane. +struct MarkedLine { + line: Line<'static>, + marker: Marker, +} + +impl super::scroll_pane::ScrollItem for MarkedLine { + fn content(&self) -> ratatui::text::Text<'_> { + ratatui::text::Text::from(self.line.clone()) + } + + fn gutter(&self) -> Option> { + self.marker.gutter_span() + } +} + #[derive(PartialEq)] enum PaneTarget { Conversation, @@ -1050,7 +1076,7 @@ fn draw_conversation_pane( pane: &mut PaneState, is_active: bool, ) { - use super::scroll_pane::visible_range; + use super::scroll_pane::ScrollPane; let border_style = if is_active { Style::default().fg(Color::Cyan) @@ -1063,36 +1089,13 @@ fn draw_conversation_pane( .borders(Borders::ALL) .border_style(border_style); - let inner = block.inner(area); - frame.render_widget(block, area); - - if inner.width < 5 || inner.height == 0 { - return; - } - - 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; - let (lines, markers) = pane.all_lines_with_markers(); - pane.scroll.ensure_heights_for_lines(&lines, text_width); - 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 heights = pane.scroll.heights(); - let (first, sub_scroll, last) = visible_range(heights, pane.scroll.offset, inner.height); - - // Apply selection highlighting to visible lines - let visible_lines: Vec> = if let Some(ref sel) = pane.selection { + // Apply selection highlighting + let items: Vec = if let Some(ref sel) = pane.selection { let (sl, sc, el, ec) = sel.range(); - (first..last).map(|i| { - let line = &lines[i]; - if i >= sl && i <= el { + lines.into_iter().zip(markers).enumerate().map(|(i, (line, marker))| { + let line = 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() }; @@ -1106,36 +1109,22 @@ fn draw_conversation_pane( 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 { - line.clone() + line } } else { - line.clone() - } + line + }; + MarkedLine { line, marker } }).collect() } else { - lines[first..last].to_vec() + lines.into_iter().zip(markers).map(|(line, marker)| MarkedLine { line, marker }).collect() }; - let text_para = Paragraph::new(visible_lines) - .wrap(Wrap { trim: false }) - .scroll((sub_scroll, 0)); - frame.render_widget(text_para, text_area); - - // Build gutter for the visible slice - let mut gutter_lines: Vec> = Vec::new(); - for i in first..last { - 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(""), - }); - for _ in 1..heights[i] { - gutter_lines.push(Line::raw("")); - } - } - - let gutter_para = Paragraph::new(gutter_lines).scroll((sub_scroll, 0)); - frame.render_widget(gutter_para, gutter_area); + let widget = ScrollPane::new(&items) + .block(block) + .gutter_width(2) + .pin_to_bottom(true); + frame.render_stateful_widget(widget, area, &mut pane.scroll); } /// Draw a scrollable text pane (free function to avoid borrow issues). @@ -1147,7 +1136,7 @@ fn draw_pane( is_active: bool, left_title: Option<&str>, ) { - use super::scroll_pane::visible_range; + use super::scroll_pane::ScrollPane; let border_style = if is_active { Style::default().fg(Color::Cyan) @@ -1166,22 +1155,9 @@ fn draw_pane( block = block.title(format!(" {} ", title)); } - let text_width = area.width.saturating_sub(2); - let inner_height = area.height.saturating_sub(2); let lines = pane.all_lines(); - pane.scroll.ensure_heights_for_lines(&lines, text_width); - - 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 (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()) - .wrap(Wrap { trim: false }) - .scroll((sub_scroll, 0)); - frame.render_widget(paragraph, area); + let widget = ScrollPane::new(&lines) + .block(block) + .pin_to_bottom(true); + frame.render_stateful_widget(widget, area, &mut pane.scroll); } diff --git a/src/user/scroll_pane.rs b/src/user/scroll_pane.rs index 150ca70..53106da 100644 --- a/src/user/scroll_pane.rs +++ b/src/user/scroll_pane.rs @@ -40,23 +40,6 @@ impl ScrollItem for Text<'static> { } } -/// 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 { @@ -106,11 +89,6 @@ impl ScrollPaneState { } } - 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) } @@ -126,11 +104,6 @@ impl ScrollPaneState { 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)> { @@ -162,15 +135,6 @@ impl ScrollPaneState { }); } - /// 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;