fork to do stuff
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}