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_model: Option<usize>, // 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
session_tx: mpsc::UnboundedSender<SessionEvent>,
streaming: HashSet<Uuid>,
textarea: TextArea<'static>, // Advanced text input widget
@@ -86,6 +88,8 @@ impl ChatApp {
selected_provider_index: 0,
selected_model: None,
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,
streaming: std::collections::HashSet::new(),
textarea,
@@ -396,9 +400,9 @@ impl ChatApp {
message_id,
response,
} => {
let at_bottom = self.scroll == 0;
let was_at_bottom = self.is_at_bottom();
self.controller.apply_stream_chunk(message_id, &response)?;
if at_bottom {
if was_at_bottom {
self.scroll_to_bottom();
}
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) {
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) {

View File

@@ -10,7 +10,7 @@ use unicode_width::UnicodeWidthStr;
use crate::chat_app::{ChatApp, InputMode};
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
let available_width = frame.area().width;
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);
}
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 mut formatter = app.formatter().clone();
// 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));
let mut lines: Vec<Line> = Vec::new();