From c17af3fee5e11e68a57ef330bb58bb2df5cb1ee7 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Mon, 29 Sep 2025 22:12:45 +0200 Subject: [PATCH] Refactor TUI scrolling logic: replace manual scroll calculations with `AutoScroll` abstraction, enhance line wrapping, and improve viewport handling. --- crates/owlen-tui/src/chat_app.rs | 119 ++++++++++++++++--------------- crates/owlen-tui/src/ui.rs | 29 ++++++-- 2 files changed, 86 insertions(+), 62 deletions(-) diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index 75aa2fc..17cb825 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -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,15 +97,15 @@ 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, // Index into the *filtered* models list - scroll: usize, - viewport_height: usize, // Track the height of the messages viewport - content_width: usize, // Track the content width for line wrapping calculations + 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, streaming: HashSet, - textarea: TextArea<'static>, // Advanced text input widget - pending_llm_request: bool, // Flag to indicate LLM request needs to be processed - loading_animation_frame: usize, // Frame counter for loading animation - is_loading: bool, // Whether we're currently loading a response + textarea: TextArea<'static>, // Advanced text input widget + pending_llm_request: bool, // Flag to indicate LLM request needs to be processed + loading_animation_frame: usize, // Frame counter for loading animation + is_loading: bool, // Whether we're currently loading a response } impl ChatApp { @@ -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(); diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index 13cd7b9..9c60252 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -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 = 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 = { + 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); }