fork to do stuff
at main 311 lines 9.6 kB view raw
1use anyhow::Result; 2use chrono::{DateTime, Utc}; 3use crossterm::{ 4 event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind}, 5 execute, 6 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, 7}; 8use ratatui::{ 9 backend::CrosstermBackend, 10 layout::{Constraint, Direction, Layout}, 11 style::{Color, Modifier, Style}, 12 text::{Line, Span, Text}, 13 widgets::{Block, Borders, Paragraph, Wrap}, 14 Frame, Terminal, 15}; 16use std::{ 17 io::{self, Stdout}, 18 time::Duration, 19}; 20use tokio::sync::mpsc; 21 22use crate::client::AtProtoClient; 23 24#[derive(Debug, Clone)] 25pub struct Message { 26 pub handle: String, 27 pub content: String, 28 pub timestamp: DateTime<Utc>, 29 pub is_own: bool, 30} 31 32impl Message { 33 pub fn new( 34 handle: String, 35 content: String, 36 is_own: bool, 37 timestamp: Option<DateTime<Utc>>, 38 ) -> Self { 39 Self { 40 handle, 41 content, 42 timestamp: timestamp.unwrap_or_else(Utc::now), 43 is_own, 44 } 45 } 46 47 pub fn format_display(&self) -> String { 48 let time_str = self.timestamp.format("%H:%M:%S").to_string(); 49 format!("[{}] {}: {}", time_str, self.handle, self.content) 50 } 51} 52 53pub struct TuiApp { 54 messages: Vec<Message>, 55 input: String, 56 scroll_offset: usize, 57 status: String, 58 message_count: usize, 59 connected: bool, 60 should_quit: bool, 61} 62 63impl TuiApp { 64 pub fn new() -> Self { 65 Self { 66 messages: Vec::new(), 67 input: String::new(), 68 scroll_offset: 0, 69 status: "Connecting...".to_string(), 70 message_count: 0, 71 connected: false, 72 should_quit: false, 73 } 74 } 75 76 pub fn add_message(&mut self, message: Message) { 77 self.messages.push(message); 78 self.message_count += 1; 79 80 // Keep only last 1000 messages 81 if self.messages.len() > 1000 { 82 self.messages.remove(0); 83 } 84 85 // Auto-scroll to bottom unless user is scrolling up 86 if self.scroll_offset == 0 { 87 self.scroll_offset = 0; // Stay at bottom 88 } 89 } 90 91 pub fn set_connection_status(&mut self, connected: bool) { 92 self.connected = connected; 93 self.status = if connected { 94 format!("Connected • {} messages", self.message_count) 95 } else { 96 "Reconnecting...".to_string() 97 }; 98 } 99 100 pub fn handle_input(&mut self, key: KeyCode) -> Option<String> { 101 match key { 102 KeyCode::Enter => { 103 if !self.input.is_empty() { 104 let message = self.input.clone(); 105 self.input.clear(); 106 return Some(message); 107 } 108 } 109 KeyCode::Char(c) => { 110 self.input.push(c); 111 } 112 KeyCode::Backspace => { 113 self.input.pop(); 114 } 115 KeyCode::Up => { 116 // Scroll up by 1 line 117 self.scroll_offset = self.scroll_offset.saturating_add(1); 118 } 119 KeyCode::Down => { 120 // Scroll down by 1 line 121 self.scroll_offset = self.scroll_offset.saturating_sub(1); 122 } 123 KeyCode::PageUp => { 124 // Scroll up by 10 lines 125 self.scroll_offset = self.scroll_offset.saturating_add(10); 126 } 127 KeyCode::PageDown => { 128 // Scroll down by 10 lines 129 self.scroll_offset = self.scroll_offset.saturating_sub(10); 130 } 131 KeyCode::Esc => { 132 self.should_quit = true; 133 } 134 _ => {} 135 } 136 None 137 } 138 139 pub fn should_quit(&self) -> bool { 140 self.should_quit 141 } 142 143 pub fn draw(&self, frame: &mut Frame) { 144 let vertical = Layout::default() 145 .direction(Direction::Vertical) 146 .constraints([ 147 Constraint::Min(0), // Messages area 148 Constraint::Length(3), // Status area 149 Constraint::Length(3), // Input area 150 ]) 151 .split(frame.area()); 152 153 // Render messages 154 let mut message_lines = Vec::new(); 155 156 // Convert messages to styled lines in reverse chronological order (newest first) 157 for msg in self.messages.iter().rev() { 158 let style = if msg.is_own { 159 Style::default() 160 .fg(Color::Green) 161 .add_modifier(Modifier::BOLD) 162 } else { 163 Style::default().fg(Color::White) 164 }; 165 166 message_lines.push(Line::from(Span::styled(msg.format_display(), style))); 167 } 168 169 let messages_text = Text::from(message_lines); 170 let messages_paragraph = Paragraph::new(messages_text) 171 .block(Block::default().borders(Borders::ALL).title("Messages")) 172 .wrap(Wrap { trim: true }) 173 .scroll((self.scroll_offset as u16, 0)); 174 frame.render_widget(messages_paragraph, vertical[0]); 175 176 // Render status 177 let status_style = if self.connected { 178 Style::default().fg(Color::Green) 179 } else { 180 Style::default().fg(Color::Yellow) 181 }; 182 183 let status_paragraph = Paragraph::new(self.status.clone()) 184 .style(status_style) 185 .block(Block::default().borders(Borders::ALL).title("Status")); 186 frame.render_widget(status_paragraph, vertical[1]); 187 188 // Render input 189 let input_paragraph = Paragraph::new(self.input.clone()).block( 190 Block::default() 191 .borders(Borders::ALL) 192 .title("Input (Esc to quit)"), 193 ); 194 frame.render_widget(input_paragraph, vertical[2]); 195 } 196} 197 198pub async fn run_tui( 199 client: &AtProtoClient, 200 mut message_rx: mpsc::UnboundedReceiver<Message>, 201) -> Result<()> { 202 // Setup terminal 203 enable_raw_mode()?; 204 let mut stdout = io::stdout(); 205 execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; 206 let backend = CrosstermBackend::new(stdout); 207 let mut terminal = Terminal::new(backend)?; 208 209 let mut app = TuiApp::new(); 210 211 // Add welcome message 212 app.add_message(Message::new( 213 "system".to_string(), 214 "Welcome to Think TUI! Connecting to jetstream...".to_string(), 215 false, 216 None, 217 )); 218 219 let result = run_tui_loop(&mut terminal, &mut app, client, &mut message_rx).await; 220 221 // Restore terminal 222 disable_raw_mode()?; 223 execute!( 224 terminal.backend_mut(), 225 LeaveAlternateScreen, 226 DisableMouseCapture 227 )?; 228 terminal.show_cursor()?; 229 230 result 231} 232 233async fn run_tui_loop( 234 terminal: &mut Terminal<CrosstermBackend<Stdout>>, 235 app: &mut TuiApp, 236 client: &AtProtoClient, 237 message_rx: &mut mpsc::UnboundedReceiver<Message>, 238) -> Result<()> { 239 loop { 240 // Draw the UI 241 terminal.draw(|f| app.draw(f))?; 242 243 // Handle events with a timeout so we can check for messages 244 if event::poll(Duration::from_millis(100))? { 245 if let Event::Key(key) = event::read()? { 246 if key.kind == KeyEventKind::Press { 247 // Handle Ctrl+C 248 if matches!(key.code, KeyCode::Char('c')) 249 && key 250 .modifiers 251 .contains(crossterm::event::KeyModifiers::CONTROL) 252 { 253 break; 254 } 255 256 // Handle other input 257 if let Some(message) = app.handle_input(key.code) { 258 // Publish the message 259 match client.publish_blip(&message).await { 260 Ok(t) => { 261 // Add our own message to the display 262 app.add_message(Message::new( 263 "you".to_string(), 264 message, 265 true, 266 DateTime::parse_from_rfc3339(&t) 267 .map(|dt| dt.with_timezone(&Utc)) 268 .ok(), // Parse RFC3339 → UTC, None if invalid (so current timestamp instead) 269 )); 270 } 271 Err(e) => { 272 // Add error message 273 app.add_message(Message::new( 274 "error".to_string(), 275 format!("Failed to publish: {}", e), 276 false, 277 None, 278 )); 279 } 280 } 281 } 282 } 283 } 284 } 285 286 // Check for new messages from jetstream 287 while let Ok(message) = message_rx.try_recv() { 288 // find most recent is_own message and see if it's already there (you posted it) 289 let duplicate = app 290 .messages 291 .iter() 292 .rev() 293 .find(|m| m.is_own) 294 .is_some_and(|m| m.timestamp == message.timestamp); 295 296 if duplicate { 297 continue; 298 } 299 300 app.add_message(message); 301 app.set_connection_status(true); 302 } 303 304 // Check if we should quit 305 if app.should_quit() { 306 break; 307 } 308 } 309 310 Ok(()) 311}