Refactor TUI scrolling logic: replace manual scroll calculations with AutoScroll abstraction, enhance line wrapping, and improve viewport handling.
This commit is contained in:
@@ -19,6 +19,40 @@ pub enum AppState {
|
|||||||
Quit,
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum InputMode {
|
pub enum InputMode {
|
||||||
Normal,
|
Normal,
|
||||||
@@ -63,7 +97,7 @@ pub struct ChatApp {
|
|||||||
pub selected_provider: String, // The currently selected provider
|
pub selected_provider: String, // The currently selected provider
|
||||||
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,
|
auto_scroll: AutoScroll, // Auto-scroll state for message rendering
|
||||||
viewport_height: usize, // Track the height of the messages viewport
|
viewport_height: usize, // Track the height of the messages viewport
|
||||||
content_width: usize, // Track the content width for line wrapping calculations
|
content_width: usize, // Track the content width for line wrapping calculations
|
||||||
session_tx: mpsc::UnboundedSender<SessionEvent>,
|
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: "ollama".to_string(), // Default, will be updated in initialize_models
|
||||||
selected_provider_index: 0,
|
selected_provider_index: 0,
|
||||||
selected_model: None,
|
selected_model: None,
|
||||||
scroll: 0,
|
auto_scroll: AutoScroll::default(),
|
||||||
viewport_height: 10, // Default viewport height, will be updated during rendering
|
viewport_height: 10, // Default viewport height, will be updated during rendering
|
||||||
content_width: 80, // Default content width, will be updated during rendering
|
content_width: 80, // Default content width, will be updated during rendering
|
||||||
session_tx,
|
session_tx,
|
||||||
@@ -139,8 +173,16 @@ impl ChatApp {
|
|||||||
self.selected_model
|
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 {
|
pub fn scroll(&self) -> usize {
|
||||||
self.scroll
|
self.auto_scroll.scroll
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn message_count(&self) -> usize {
|
pub fn message_count(&self) -> usize {
|
||||||
@@ -269,10 +311,10 @@ impl ChatApp {
|
|||||||
self.sync_buffer_to_textarea();
|
self.sync_buffer_to_textarea();
|
||||||
}
|
}
|
||||||
(KeyCode::Up, KeyModifiers::NONE) => {
|
(KeyCode::Up, KeyModifiers::NONE) => {
|
||||||
self.scroll = self.scroll.saturating_add(1);
|
self.on_scroll(-1isize);
|
||||||
}
|
}
|
||||||
(KeyCode::Down, KeyModifiers::NONE) => {
|
(KeyCode::Down, KeyModifiers::NONE) => {
|
||||||
self.scroll = self.scroll.saturating_sub(1);
|
self.on_scroll(1isize);
|
||||||
}
|
}
|
||||||
(KeyCode::Esc, KeyModifiers::NONE) => {
|
(KeyCode::Esc, KeyModifiers::NONE) => {
|
||||||
self.mode = InputMode::Normal;
|
self.mode = InputMode::Normal;
|
||||||
@@ -400,17 +442,19 @@ impl ChatApp {
|
|||||||
Ok(AppState::Running)
|
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<()> {
|
pub fn handle_session_event(&mut self, event: SessionEvent) -> Result<()> {
|
||||||
match event {
|
match event {
|
||||||
SessionEvent::StreamChunk {
|
SessionEvent::StreamChunk {
|
||||||
message_id,
|
message_id,
|
||||||
response,
|
response,
|
||||||
} => {
|
} => {
|
||||||
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 was_at_bottom {
|
// Auto-scroll will handle this in the render loop
|
||||||
self.scroll_to_bottom();
|
|
||||||
}
|
|
||||||
if response.is_final {
|
if response.is_final {
|
||||||
self.streaming.remove(&message_id);
|
self.streaming.remove(&message_id);
|
||||||
self.stop_loading_animation();
|
self.stop_loading_animation();
|
||||||
@@ -492,7 +536,9 @@ impl ChatApp {
|
|||||||
// Step 1: Add user message to conversation immediately (synchronous)
|
// Step 1: Add user message to conversation immediately (synchronous)
|
||||||
let message = self.controller.input_buffer_mut().commit_to_history();
|
let message = self.controller.input_buffer_mut().commit_to_history();
|
||||||
self.controller.conversation_mut().push_user_message(message.clone());
|
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
|
// Step 2: Set flag to process LLM request on next event loop iteration
|
||||||
self.pending_llm_request = true;
|
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) {
|
fn spawn_stream(&mut self, message_id: Uuid, mut stream: owlen_core::provider::ChatStream) {
|
||||||
let sender = self.session_tx.clone();
|
let sender = self.session_tx.clone();
|
||||||
|
|||||||
@@ -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
|
// Reserve space for borders and the message indent so text fits within the block
|
||||||
formatter.set_wrap_width(usize::from(content_width));
|
formatter.set_wrap_width(usize::from(content_width));
|
||||||
|
|
||||||
|
// Build the lines for messages
|
||||||
let mut lines: Vec<Line> = Vec::new();
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
for (message_index, message) in conversation.messages.iter().enumerate() {
|
for (message_index, message) in conversation.messages.iter().enumerate() {
|
||||||
let role = &message.role;
|
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."));
|
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(
|
||||||
Block::default()
|
Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_style(Style::default().fg(Color::Rgb(95, 20, 135))),
|
.border_style(Style::default().fg(Color::Rgb(95, 20, 135))),
|
||||||
)
|
)
|
||||||
.wrap(Wrap { trim: false });
|
.wrap(Wrap { trim: false })
|
||||||
|
.scroll((scroll_position, 0));
|
||||||
let scroll = app.scroll().min(u16::MAX as usize) as u16;
|
|
||||||
paragraph = paragraph.scroll((scroll, 0));
|
|
||||||
|
|
||||||
frame.render_widget(paragraph, area);
|
frame.render_widget(paragraph, area);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user