Refactor TUI scrolling logic: replace manual scroll calculations with AutoScroll abstraction, enhance line wrapping, and improve viewport handling.

This commit is contained in:
2025-09-29 22:12:45 +02:00
parent a4ba9adf8f
commit c17af3fee5
2 changed files with 86 additions and 62 deletions

View File

@@ -19,6 +19,40 @@ pub enum AppState {
Quit,
}
pub struct AutoScroll {
pub scroll: usize,
pub content_len: usize,
pub stick_to_bottom: bool,
}
impl Default for AutoScroll {
fn default() -> Self {
Self {
scroll: 0,
content_len: 0,
stick_to_bottom: true,
}
}
}
impl AutoScroll {
pub fn on_viewport(&mut self, viewport_h: usize) {
let max = self.content_len.saturating_sub(viewport_h);
if self.stick_to_bottom {
self.scroll = max;
} else {
self.scroll = self.scroll.min(max);
}
}
pub fn on_user_scroll(&mut self, delta: isize, viewport_h: usize) {
let max = self.content_len.saturating_sub(viewport_h) as isize;
let s = (self.scroll as isize + delta).clamp(0, max) as usize;
self.scroll = s;
self.stick_to_bottom = s as isize == max;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputMode {
Normal,
@@ -63,7 +97,7 @@ pub struct ChatApp {
pub selected_provider: String, // The currently selected provider
pub selected_provider_index: usize, // Index into the available_providers list
pub selected_model: Option<usize>, // Index into the *filtered* models list
scroll: usize,
auto_scroll: AutoScroll, // Auto-scroll state for message rendering
viewport_height: usize, // Track the height of the messages viewport
content_width: usize, // Track the content width for line wrapping calculations
session_tx: mpsc::UnboundedSender<SessionEvent>,
@@ -90,7 +124,7 @@ impl ChatApp {
selected_provider: "ollama".to_string(), // Default, will be updated in initialize_models
selected_provider_index: 0,
selected_model: None,
scroll: 0,
auto_scroll: AutoScroll::default(),
viewport_height: 10, // Default viewport height, will be updated during rendering
content_width: 80, // Default content width, will be updated during rendering
session_tx,
@@ -139,8 +173,16 @@ impl ChatApp {
self.selected_model
}
pub fn auto_scroll(&self) -> &AutoScroll {
&self.auto_scroll
}
pub fn auto_scroll_mut(&mut self) -> &mut AutoScroll {
&mut self.auto_scroll
}
pub fn scroll(&self) -> usize {
self.scroll
self.auto_scroll.scroll
}
pub fn message_count(&self) -> usize {
@@ -269,10 +311,10 @@ impl ChatApp {
self.sync_buffer_to_textarea();
}
(KeyCode::Up, KeyModifiers::NONE) => {
self.scroll = self.scroll.saturating_add(1);
self.on_scroll(-1isize);
}
(KeyCode::Down, KeyModifiers::NONE) => {
self.scroll = self.scroll.saturating_sub(1);
self.on_scroll(1isize);
}
(KeyCode::Esc, KeyModifiers::NONE) => {
self.mode = InputMode::Normal;
@@ -400,17 +442,19 @@ impl ChatApp {
Ok(AppState::Running)
}
/// Call this when processing scroll up/down keys
pub fn on_scroll(&mut self, delta: isize) {
self.auto_scroll.on_user_scroll(delta, self.viewport_height);
}
pub fn handle_session_event(&mut self, event: SessionEvent) -> Result<()> {
match event {
SessionEvent::StreamChunk {
message_id,
response,
} => {
let was_at_bottom = self.is_at_bottom();
self.controller.apply_stream_chunk(message_id, &response)?;
if was_at_bottom {
self.scroll_to_bottom();
}
// Auto-scroll will handle this in the render loop
if response.is_final {
self.streaming.remove(&message_id);
self.stop_loading_animation();
@@ -492,7 +536,9 @@ impl ChatApp {
// Step 1: Add user message to conversation immediately (synchronous)
let message = self.controller.input_buffer_mut().commit_to_history();
self.controller.conversation_mut().push_user_message(message.clone());
self.scroll_to_bottom();
// Auto-scroll to bottom when sending a message
self.auto_scroll.stick_to_bottom = true;
// Step 2: Set flag to process LLM request on next event loop iteration
self.pending_llm_request = true;
@@ -634,48 +680,7 @@ impl ChatApp {
}
}
fn is_at_bottom(&self) -> bool {
let total_lines = self.calculate_total_content_lines();
let max_scroll = total_lines.saturating_sub(self.viewport_height);
// We're at bottom if scroll is at or near the maximum scroll position
self.scroll >= max_scroll || total_lines <= self.viewport_height
}
fn calculate_total_content_lines(&self) -> usize {
let conversation = self.controller.conversation();
let mut formatter = self.controller.formatter().clone();
// Set the wrap width to match the current display
formatter.set_wrap_width(self.content_width);
let mut total_lines = 0;
for (message_index, message) in conversation.messages.iter().enumerate() {
let formatted = formatter.format_message(message);
let show_role_labels = formatter.show_role_labels();
// Add role label line if enabled
if show_role_labels {
total_lines += 1;
}
// Add message content lines
total_lines += formatted.len();
// Add empty line after each message, except the last one
if message_index < conversation.messages.len() - 1 {
total_lines += 1;
}
}
// Minimum 1 line for empty state
total_lines.max(1)
}
fn scroll_to_bottom(&mut self) {
let total_lines = self.calculate_total_content_lines();
self.scroll = total_lines.saturating_sub(self.viewport_height);
}
fn spawn_stream(&mut self, message_id: Uuid, mut stream: owlen_core::provider::ChatStream) {
let sender = self.session_tx.clone();

View File

@@ -357,6 +357,7 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
// Reserve space for borders and the message indent so text fits within the block
formatter.set_wrap_width(usize::from(content_width));
// Build the lines for messages
let mut lines: Vec<Line> = Vec::new();
for (message_index, message) in conversation.messages.iter().enumerate() {
let role = &message.role;
@@ -434,16 +435,34 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
lines.push(Line::from("No messages yet. Press 'i' to start typing."));
}
let mut paragraph = Paragraph::new(lines)
// Wrap lines to get accurate content height
let wrapped: Vec<Line> = {
use textwrap::wrap;
let mut out = Vec::new();
for l in &lines {
let s = l.to_string();
for w in wrap(&s, content_width as usize) {
out.push(Line::from(w.into_owned()));
}
}
out
};
// Update AutoScroll state with accurate content length
let auto_scroll = app.auto_scroll_mut();
auto_scroll.content_len = wrapped.len();
auto_scroll.on_viewport(viewport_height);
let scroll_position = app.scroll().min(u16::MAX as usize) as u16;
let paragraph = Paragraph::new(wrapped)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Rgb(95, 20, 135))),
)
.wrap(Wrap { trim: false });
let scroll = app.scroll().min(u16::MAX as usize) as u16;
paragraph = paragraph.scroll((scroll, 0));
.wrap(Wrap { trim: false })
.scroll((scroll_position, 0));
frame.render_widget(paragraph, area);
}