Enhance TUI scrolling: add viewport dimension tracking, autoscroll logic, and message line calculation. Refactor related components for dynamic rendering.

This commit is contained in:
2025-09-28 16:45:49 +02:00
parent 9de24f7de6
commit fcdbd2bf98
2 changed files with 58 additions and 6 deletions

View File

@@ -64,6 +64,8 @@ pub struct ChatApp {
pub selected_provider_index: usize, // Index into the available_providers list pub selected_provider_index: usize, // Index into the available_providers list
pub selected_model: Option<usize>, // Index into the *filtered* models list pub selected_model: Option<usize>, // Index into the *filtered* models list
scroll: usize, scroll: usize,
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>, session_tx: mpsc::UnboundedSender<SessionEvent>,
streaming: HashSet<Uuid>, streaming: HashSet<Uuid>,
textarea: TextArea<'static>, // Advanced text input widget textarea: TextArea<'static>, // Advanced text input widget
@@ -86,6 +88,8 @@ impl ChatApp {
selected_provider_index: 0, selected_provider_index: 0,
selected_model: None, selected_model: None,
scroll: 0, scroll: 0,
viewport_height: 10, // Default viewport height, will be updated during rendering
content_width: 80, // Default content width, will be updated during rendering
session_tx, session_tx,
streaming: std::collections::HashSet::new(), streaming: std::collections::HashSet::new(),
textarea, textarea,
@@ -396,9 +400,9 @@ impl ChatApp {
message_id, message_id,
response, response,
} => { } => {
let at_bottom = self.scroll == 0; let was_at_bottom = self.is_at_bottom();
self.controller.apply_stream_chunk(message_id, &response)?; self.controller.apply_stream_chunk(message_id, &response)?;
if at_bottom { if was_at_bottom {
self.scroll_to_bottom(); self.scroll_to_bottom();
} }
if response.is_final { if response.is_final {
@@ -559,8 +563,52 @@ impl ChatApp {
} }
} }
pub fn set_viewport_dimensions(&mut self, height: usize, content_width: usize) {
self.viewport_height = height;
self.content_width = content_width;
}
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) { fn scroll_to_bottom(&mut self) {
self.scroll = 0; 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) { fn spawn_stream(&mut self, message_id: Uuid, mut stream: owlen_core::provider::ChatStream) {

View File

@@ -10,7 +10,7 @@ use unicode_width::UnicodeWidthStr;
use crate::chat_app::{ChatApp, InputMode}; use crate::chat_app::{ChatApp, InputMode};
use owlen_core::types::Role; use owlen_core::types::Role;
pub fn render_chat(frame: &mut Frame<'_>, app: &ChatApp) { pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
// Calculate dynamic input height based on textarea content // Calculate dynamic input height based on textarea content
let available_width = frame.area().width; let available_width = frame.area().width;
let input_height = if matches!(app.mode(), InputMode::Editing) { let input_height = if matches!(app.mode(), InputMode::Editing) {
@@ -345,12 +345,16 @@ fn render_header(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
frame.render_widget(paragraph, inner_area); frame.render_widget(paragraph, inner_area);
} }
fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
// Calculate viewport dimensions for autoscroll calculations
let viewport_height = area.height.saturating_sub(2) as usize; // subtract borders
let content_width = area.width.saturating_sub(4).max(20);
app.set_viewport_dimensions(viewport_height, usize::from(content_width));
let conversation = app.conversation(); let conversation = app.conversation();
let mut formatter = app.formatter().clone(); let mut formatter = app.formatter().clone();
// Reserve space for borders and the message indent so text fits within the block // Reserve space for borders and the message indent so text fits within the block
let content_width = area.width.saturating_sub(4).max(20);
formatter.set_wrap_width(usize::from(content_width)); formatter.set_wrap_width(usize::from(content_width));
let mut lines: Vec<Line> = Vec::new(); let mut lines: Vec<Line> = Vec::new();