Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
9.1 kB · 287 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287use anyhow::Result;use chrono::{DateTime, Utc};use crossterm::{ event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},};use ratatui::{ backend::CrosstermBackend, layout::{Constraint, Direction, Layout}, style::{Color, Modifier, Style}, text::{Line, Span, Text}, widgets::{Block, Borders, Paragraph, Wrap}, Frame, Terminal,};use std::{ io::{self, Stdout}, time::Duration,};use tokio::sync::mpsc;
use crate::client::AtProtoClient;
#[derive(Debug, Clone)]pub struct Message { pub handle: String, pub content: String, pub timestamp: DateTime<Utc>, pub is_own: bool,}
impl Message { pub fn new(handle: String, content: String, is_own: bool) -> Self { Self { handle, content, timestamp: Utc::now(), is_own, } }
pub fn format_display(&self) -> String { let time_str = self.timestamp.format("%H:%M:%S").to_string(); format!("[{}] {}: {}", time_str, self.handle, self.content) }}
pub struct TuiApp { messages: Vec<Message>, input: String, scroll_offset: usize, status: String, message_count: usize, connected: bool, should_quit: bool,}
impl TuiApp { pub fn new() -> Self { Self { messages: Vec::new(), input: String::new(), scroll_offset: 0, status: "Connecting...".to_string(), message_count: 0, connected: false, should_quit: false, } }
pub fn add_message(&mut self, message: Message) { self.messages.push(message); self.message_count += 1; // Keep only last 1000 messages if self.messages.len() > 1000 { self.messages.remove(0); } // Auto-scroll to bottom unless user is scrolling up if self.scroll_offset == 0 { self.scroll_offset = 0; // Stay at bottom } }
pub fn set_connection_status(&mut self, connected: bool) { self.connected = connected; self.status = if connected { format!("Connected • {} messages", self.message_count) } else { "Reconnecting...".to_string() }; }
pub fn handle_input(&mut self, key: KeyCode) -> Option<String> { match key { KeyCode::Enter => { if !self.input.is_empty() { let message = self.input.clone(); self.input.clear(); return Some(message); } } KeyCode::Char(c) => { self.input.push(c); } KeyCode::Backspace => { self.input.pop(); } KeyCode::Up => { // Scroll up by 1 line self.scroll_offset = self.scroll_offset.saturating_add(1); } KeyCode::Down => { // Scroll down by 1 line self.scroll_offset = self.scroll_offset.saturating_sub(1); } KeyCode::PageUp => { // Scroll up by 10 lines self.scroll_offset = self.scroll_offset.saturating_add(10); } KeyCode::PageDown => { // Scroll down by 10 lines self.scroll_offset = self.scroll_offset.saturating_sub(10); } KeyCode::Esc => { self.should_quit = true; } _ => {} } None }
pub fn should_quit(&self) -> bool { self.should_quit }
pub fn draw(&self, frame: &mut Frame) { let vertical = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Min(0), // Messages area Constraint::Length(3), // Status area Constraint::Length(3), // Input area ]) .split(frame.area());
// Render messages let mut message_lines = Vec::new(); // Convert messages to styled lines in reverse chronological order (newest first) for msg in self.messages.iter().rev() { let style = if msg.is_own { Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::White) }; message_lines.push(Line::from(Span::styled(msg.format_display(), style))); } let messages_text = Text::from(message_lines); let messages_paragraph = Paragraph::new(messages_text) .block(Block::default().borders(Borders::ALL).title("Messages")) .wrap(Wrap { trim: true }) .scroll((self.scroll_offset as u16, 0)); frame.render_widget(messages_paragraph, vertical[0]);
// Render status let status_style = if self.connected { Style::default().fg(Color::Green) } else { Style::default().fg(Color::Yellow) }; let status_paragraph = Paragraph::new(self.status.clone()) .style(status_style) .block(Block::default().borders(Borders::ALL).title("Status")); frame.render_widget(status_paragraph, vertical[1]);
// Render input let input_paragraph = Paragraph::new(self.input.clone()) .block(Block::default().borders(Borders::ALL).title("Input (Esc to quit)")); frame.render_widget(input_paragraph, vertical[2]); }}
pub async fn run_tui( client: &mut AtProtoClient, mut message_rx: mpsc::UnboundedReceiver<Message>,) -> Result<()> { // Setup terminal enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?;
let mut app = TuiApp::new(); // Add welcome message app.add_message(Message::new( "system".to_string(), "Welcome to Think TUI! Connecting to jetstream...".to_string(), false, ));
let result = run_tui_loop(&mut terminal, &mut app, client, &mut message_rx).await;
// Restore terminal disable_raw_mode()?; execute!( terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture )?; terminal.show_cursor()?;
result}
async fn run_tui_loop( terminal: &mut Terminal<CrosstermBackend<Stdout>>, app: &mut TuiApp, client: &mut AtProtoClient, message_rx: &mut mpsc::UnboundedReceiver<Message>,) -> Result<()> { loop { // Draw the UI terminal.draw(|f| app.draw(f))?;
// Handle events with a timeout so we can check for messages if event::poll(Duration::from_millis(100))? { if let Event::Key(key) = event::read()? { if key.kind == KeyEventKind::Press { // Handle Ctrl+C if matches!(key.code, KeyCode::Char('c')) && key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) { break; }
// Handle other input if let Some(message) = app.handle_input(key.code) { // Publish the message match client.publish_blip(&message).await { Ok(_) => { // Add our own message to the display app.add_message(Message::new( "you".to_string(), message, true, ));
// Save updated session (in case token was refreshed) if let Ok(store) = crate::credentials::CredentialStore::new() { if let Some(session) = client.get_session() { let _ = store.store_session(session); } } } Err(e) => { // Add error message app.add_message(Message::new( "error".to_string(), format!("Failed to publish: {}", e), false, )); } } } } } }
// Check for new messages from jetstream while let Ok(message) = message_rx.try_recv() { app.add_message(message); app.set_connection_status(true); }
// Check if we should quit if app.should_quit() { break; } }
Ok(())}