A Rust CLI for publishing thought records. Designed to work with thought.stream.

Improve TUI scrolling behavior and message rendering

- Replace List widget with Paragraph for better text handling and wrapping
- Fix scrolling direction to match intuitive expectations (up/down arrows)
- Add proper line-based scrolling with saturation to prevent overflow
- Enable text wrapping for long messages
- Update frame.size() to frame.area() for newer ratatui API

+29 -32
+29 -32
src/tui.rs
··· 9 9 backend::CrosstermBackend, 10 10 layout::{Constraint, Direction, Layout}, 11 11 style::{Color, Modifier, Style}, 12 - text::{Line, Span}, 13 - widgets::{Block, Borders, List, ListItem, Paragraph}, 12 + text::{Line, Span, Text}, 13 + widgets::{Block, Borders, Paragraph, Wrap}, 14 14 Frame, Terminal, 15 15 }; 16 16 use std::{ ··· 108 108 self.input.pop(); 109 109 } 110 110 KeyCode::Up => { 111 - if self.scroll_offset < self.messages.len().saturating_sub(1) { 112 - self.scroll_offset += 1; 113 - } 111 + // Scroll up by 1 line 112 + self.scroll_offset = self.scroll_offset.saturating_add(1); 114 113 } 115 114 KeyCode::Down => { 116 - if self.scroll_offset > 0 { 117 - self.scroll_offset -= 1; 118 - } 115 + // Scroll down by 1 line 116 + self.scroll_offset = self.scroll_offset.saturating_sub(1); 119 117 } 120 118 KeyCode::PageUp => { 121 - self.scroll_offset = (self.scroll_offset + 10).min(self.messages.len().saturating_sub(1)); 119 + // Scroll up by 10 lines 120 + self.scroll_offset = self.scroll_offset.saturating_add(10); 122 121 } 123 122 KeyCode::PageDown => { 123 + // Scroll down by 10 lines 124 124 self.scroll_offset = self.scroll_offset.saturating_sub(10); 125 125 } 126 126 KeyCode::Esc => { ··· 143 143 Constraint::Length(3), // Status area 144 144 Constraint::Length(3), // Input area 145 145 ]) 146 - .split(frame.size()); 146 + .split(frame.area()); 147 147 148 148 // Render messages 149 - let visible_messages: Vec<ListItem> = self.messages 150 - .iter() 151 - .rev() 152 - .skip(self.scroll_offset) 153 - .take(vertical[0].height as usize - 2) // Account for borders 154 - .map(|msg| { 155 - let style = if msg.is_own { 156 - Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) 157 - } else { 158 - Style::default().fg(Color::White) 159 - }; 160 - 161 - ListItem::new(Line::from(Span::styled(msg.format_display(), style))) 162 - }) 163 - .collect::<Vec<_>>() 164 - .into_iter() 165 - .rev() 166 - .collect(); 167 - 168 - let messages_list = List::new(visible_messages) 169 - .block(Block::default().borders(Borders::ALL).title("Messages")); 170 - frame.render_widget(messages_list, vertical[0]); 149 + let mut message_lines = Vec::new(); 150 + 151 + // Convert messages to styled lines in reverse chronological order (newest first) 152 + for msg in self.messages.iter().rev() { 153 + let style = if msg.is_own { 154 + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) 155 + } else { 156 + Style::default().fg(Color::White) 157 + }; 158 + 159 + message_lines.push(Line::from(Span::styled(msg.format_display(), style))); 160 + } 161 + 162 + let messages_text = Text::from(message_lines); 163 + let messages_paragraph = Paragraph::new(messages_text) 164 + .block(Block::default().borders(Borders::ALL).title("Messages")) 165 + .wrap(Wrap { trim: true }) 166 + .scroll((self.scroll_offset as u16, 0)); 167 + frame.render_widget(messages_paragraph, vertical[0]); 171 168 172 169 // Render status 173 170 let status_style = if self.connected {