1use std::io;
2
3use crossterm::{
4 event::{self, DisableBracketedPaste, EnableBracketedPaste, Event},
5 execute,
6 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
7};
8use ratatui::{
9 backend::CrosstermBackend,
10 layout::{Constraint, Direction, Layout, Rect},
11 style::{Color, Modifier, Style},
12 text::{Line, Span, Text},
13 widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
14 Terminal,
15};
16use tokio::{
17 io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
18 sync::mpsc,
19};
20
21mod input;
22mod render;
23mod widgets;
24
25use crate::message::Message;
26use input::{
27 build_payload, cursor_display_pos, handle_key, seed_online_users_from_who, wrap_input_display,
28 Action, InputState,
29};
30use render::{assign_color, find_view_start, format_message, user_color, welcome_splash, ColorMap};
31
32const MAX_INPUT_LINES: usize = 6;
34
35pub async fn run(
36 reader: BufReader<tokio::net::unix::OwnedReadHalf>,
37 mut write_half: tokio::net::unix::OwnedWriteHalf,
38 room_id: &str,
39 username: &str,
40 history_lines: usize,
41) -> anyhow::Result<()> {
42 let (msg_tx, mut msg_rx) = mpsc::unbounded_channel::<Message>();
43 let username_owned = username.to_owned();
44
45 tokio::spawn(async move {
48 let mut reader = reader;
49 let mut history_buf: Vec<Message> = Vec::new();
50 let mut joined = false;
51 let mut line = String::new();
52
53 loop {
54 line.clear();
55 match reader.read_line(&mut line).await {
56 Ok(0) => break,
57 Ok(_) => {
58 let trimmed = line.trim();
59 if trimmed.is_empty() {
60 continue;
61 }
62 let Ok(msg) = serde_json::from_str::<Message>(trimmed) else {
63 continue;
64 };
65
66 if joined {
67 let _ = msg_tx.send(msg);
68 } else {
69 let is_own_join =
70 matches!(&msg, Message::Join { user, .. } if user == &username_owned);
71 if is_own_join {
72 joined = true;
73 let start = history_buf.len().saturating_sub(history_lines);
75 for h in history_buf.drain(start..) {
76 let _ = msg_tx.send(h);
77 }
78 let _ = msg_tx.send(msg);
79 } else {
80 history_buf.push(msg);
81 }
82 }
83 }
84 Err(_) => break,
85 }
86 }
87 });
88
89 enable_raw_mode()?;
91 let mut stdout = io::stdout();
92 execute!(stdout, EnterAlternateScreen, EnableBracketedPaste)?;
93 let backend = CrosstermBackend::new(stdout);
94 let mut terminal = Terminal::new(backend)?;
95
96 let mut messages: Vec<Message> = Vec::new();
97 let mut online_users: Vec<String> = Vec::new();
98 let mut color_map = ColorMap::new();
99 let mut state = InputState::new();
100 let mut result: anyhow::Result<()> = Ok(());
101 let mut frame_count: usize = 0;
102
103 let who_payload = build_payload("/who");
106 write_half
107 .write_all(format!("{who_payload}\n").as_bytes())
108 .await?;
109
110 'main: loop {
111 loop {
114 match msg_rx.try_recv() {
115 Ok(msg) => {
116 match &msg {
117 Message::Join { user, .. } if !online_users.contains(user) => {
118 assign_color(user, &mut color_map);
119 online_users.push(user.clone());
120 }
121 Message::Leave { user, .. } => {
122 online_users.retain(|u| u != user);
123 }
124 Message::Message { user, .. } if !online_users.contains(user) => {
127 assign_color(user, &mut color_map);
128 online_users.push(user.clone());
129 }
130 Message::Message { user, .. } => {
132 assign_color(user, &mut color_map);
133 }
134 Message::System { user, content, .. } if user == "broker" => {
136 seed_online_users_from_who(content, &mut online_users);
137 for u in &online_users {
138 assign_color(u, &mut color_map);
139 }
140 }
141 _ => {}
142 }
143 messages.push(msg);
144 }
145 Err(mpsc::error::TryRecvError::Empty) => break,
146 Err(mpsc::error::TryRecvError::Disconnected) => break 'main,
147 }
148 }
149
150 let term_area = terminal.size()?;
151 let input_content_width = term_area.width.saturating_sub(2) as usize;
153
154 let input_display_rows = wrap_input_display(&state.input, input_content_width);
156 let total_input_rows = input_display_rows.len();
157 let visible_input_lines = total_input_rows.min(MAX_INPUT_LINES);
158 let input_box_height = (visible_input_lines + 2) as u16;
160
161 let (cursor_row, cursor_col) =
162 cursor_display_pos(&state.input, state.cursor_pos, input_content_width);
163
164 if cursor_row < state.input_row_scroll {
166 state.input_row_scroll = cursor_row;
167 }
168 if visible_input_lines > 0 && cursor_row >= state.input_row_scroll + visible_input_lines {
169 state.input_row_scroll = cursor_row + 1 - visible_input_lines;
170 }
171
172 let content_width = term_area.width.saturating_sub(2) as usize;
173 let msg_area_height = {
177 let chunks = Layout::default()
178 .direction(Direction::Vertical)
179 .constraints([Constraint::Min(3), Constraint::Length(input_box_height)])
180 .split(Rect::new(0, 0, term_area.width, term_area.height));
181 chunks[0].height.saturating_sub(2) as usize
182 };
183
184 let msg_texts: Vec<Text<'static>> = messages
185 .iter()
186 .map(|m| format_message(m, content_width, &color_map))
187 .collect();
188
189 let heights: Vec<usize> = msg_texts.iter().map(|t| t.lines.len().max(1)).collect();
190 let total_lines: usize = heights.iter().sum();
191
192 state.scroll_offset = state
194 .scroll_offset
195 .min(total_lines.saturating_sub(msg_area_height));
196
197 terminal.draw(|f| {
198 let chunks = Layout::default()
199 .direction(Direction::Vertical)
200 .constraints([Constraint::Min(3), Constraint::Length(input_box_height)])
201 .split(f.area());
202
203 let view_bottom = total_lines.saturating_sub(state.scroll_offset);
204 let view_top = view_bottom.saturating_sub(msg_area_height);
205
206 let (start_msg_idx, skip_first) = find_view_start(&heights, view_top);
207
208 let visible: Vec<ListItem> = msg_texts[start_msg_idx..]
209 .iter()
210 .enumerate()
211 .map(|(i, text)| {
212 if i == 0 && skip_first > 0 {
213 ListItem::new(Text::from(text.lines[skip_first..].to_vec()))
214 } else {
215 ListItem::new(text.clone())
216 }
217 })
218 .collect();
219
220 let title = if state.scroll_offset > 0 {
221 format!(" {room_id} [↑ {} lines] ", state.scroll_offset)
222 } else {
223 format!(" {room_id} ")
224 };
225
226 let has_chat = messages.iter().any(|m| {
228 matches!(
229 m,
230 Message::Message { .. }
231 | Message::Reply { .. }
232 | Message::Command { .. }
233 | Message::DirectMessage { .. }
234 )
235 });
236
237 if !has_chat {
238 let splash_width = chunks[0].width.saturating_sub(2) as usize;
239 let splash = welcome_splash(frame_count, splash_width);
240 let splash_widget = Paragraph::new(splash)
241 .block(
242 Block::default()
243 .title(title.clone())
244 .borders(Borders::ALL)
245 .border_style(Style::default().fg(Color::DarkGray)),
246 )
247 .alignment(ratatui::layout::Alignment::Left);
248 f.render_widget(splash_widget, chunks[0]);
249 } else {
250 let msg_list = List::new(visible).block(
251 Block::default()
252 .title(title)
253 .borders(Borders::ALL)
254 .border_style(Style::default().fg(Color::DarkGray)),
255 );
256 f.render_widget(msg_list, chunks[0]);
257 }
258
259 let end = (state.input_row_scroll + visible_input_lines).min(total_input_rows);
261 let display_text = input_display_rows[state.input_row_scroll..end].join("\n");
262
263 let input_widget = Paragraph::new(display_text)
264 .block(
265 Block::default()
266 .title(format!(" {username} "))
267 .borders(Borders::ALL)
268 .border_style(Style::default().fg(Color::Cyan)),
269 )
270 .style(Style::default().fg(Color::White));
271 f.render_widget(input_widget, chunks[1]);
272
273 let visible_cursor_row = cursor_row - state.input_row_scroll;
275 let cursor_x = chunks[1].x + 1 + cursor_col as u16;
276 let cursor_y = chunks[1].y + 1 + visible_cursor_row as u16;
277 f.set_cursor_position((cursor_x, cursor_y));
278
279 if state.palette.active && !state.palette.filtered.is_empty() {
281 let palette_items: Vec<ListItem> = state
282 .palette
283 .filtered
284 .iter()
285 .enumerate()
286 .map(|(row, &idx)| {
287 let item = &state.palette.commands[idx];
288 let style = if row == state.palette.selected {
289 Style::default()
290 .fg(Color::Black)
291 .bg(Color::Cyan)
292 .add_modifier(Modifier::BOLD)
293 } else {
294 Style::default().fg(Color::White)
295 };
296 ListItem::new(Line::from(vec![
297 Span::styled(
298 format!("{:<16}", item.usage),
299 style.add_modifier(Modifier::BOLD),
300 ),
301 Span::styled(
302 format!(" {}", item.description),
303 if row == state.palette.selected {
304 Style::default().fg(Color::Black).bg(Color::Cyan)
305 } else {
306 Style::default().fg(Color::DarkGray)
307 },
308 ),
309 ]))
310 })
311 .collect();
312
313 let popup_height = (state.palette.filtered.len() as u16 + 2).min(chunks[0].height);
314 let popup_y = chunks[1].y.saturating_sub(popup_height);
315 let popup_rect = Rect {
316 x: chunks[1].x,
317 y: popup_y,
318 width: chunks[1].width,
319 height: popup_height,
320 };
321
322 f.render_widget(Clear, popup_rect);
323 let palette_list = List::new(palette_items).block(
324 Block::default()
325 .title(" commands ")
326 .borders(Borders::ALL)
327 .border_style(Style::default().fg(Color::Cyan)),
328 );
329 f.render_widget(palette_list, popup_rect);
330 }
331
332 if state.mention.active && !state.mention.filtered.is_empty() {
334 let mention_items: Vec<ListItem> = state
335 .mention
336 .filtered
337 .iter()
338 .enumerate()
339 .map(|(row, user)| {
340 let style = if row == state.mention.selected {
341 Style::default()
342 .fg(Color::Black)
343 .bg(user_color(user, &color_map))
344 .add_modifier(Modifier::BOLD)
345 } else {
346 Style::default().fg(user_color(user, &color_map))
347 };
348 ListItem::new(Line::from(Span::styled(format!("@{user}"), style)))
349 })
350 .collect();
351
352 let popup_height = (state.mention.filtered.len() as u16 + 2).min(chunks[0].height);
353 let popup_y = chunks[1].y.saturating_sub(popup_height);
354 let max_width = state
355 .mention
356 .filtered
357 .iter()
358 .map(|u| u.len() + 1) .max()
360 .unwrap_or(8) as u16
361 + 4; let popup_width = max_width.min(chunks[1].width / 2).max(8);
363 let popup_x = cursor_x
364 .saturating_sub(1)
365 .min(chunks[1].x + chunks[1].width.saturating_sub(popup_width));
366 let popup_rect = Rect {
367 x: popup_x,
368 y: popup_y,
369 width: popup_width,
370 height: popup_height,
371 };
372
373 f.render_widget(Clear, popup_rect);
374 let mention_list = List::new(mention_items).block(
375 Block::default()
376 .title(" @ ")
377 .borders(Borders::ALL)
378 .border_style(Style::default().fg(Color::Yellow)),
379 );
380 f.render_widget(mention_list, popup_rect);
381 }
382 })?;
383
384 if event::poll(std::time::Duration::from_millis(50))? {
385 match event::read()? {
386 Event::Key(key) => {
387 match handle_key(
388 key,
389 &mut state,
390 &online_users,
391 msg_area_height,
392 input_content_width,
393 ) {
394 Some(Action::Send(payload)) => {
395 if let Err(e) = write_half
396 .write_all(format!("{payload}\n").as_bytes())
397 .await
398 {
399 result = Err(e.into());
400 break 'main;
401 }
402 }
403 Some(Action::Quit) => break 'main,
404 None => {}
405 }
406 }
407 Event::Paste(text) => {
408 let clean = text.replace("\r\n", "\n").replace('\r', "\n");
410 state.input.insert_str(state.cursor_pos, &clean);
411 state.cursor_pos += clean.len();
412 state.mention.active = false;
413 }
414 Event::Resize(_, _) => {}
415 _ => {}
416 }
417 }
418
419 loop {
422 match msg_rx.try_recv() {
423 Ok(msg) => {
424 match &msg {
425 Message::Join { user, .. } if !online_users.contains(user) => {
426 assign_color(user, &mut color_map);
427 online_users.push(user.clone());
428 }
429 Message::Leave { user, .. } => {
430 online_users.retain(|u| u != user);
431 }
432 Message::Message { user, .. } if !online_users.contains(user) => {
433 assign_color(user, &mut color_map);
434 online_users.push(user.clone());
435 }
436 Message::Message { user, .. } => {
437 assign_color(user, &mut color_map);
438 }
439 Message::System { user, content, .. } if user == "broker" => {
440 seed_online_users_from_who(content, &mut online_users);
441 for u in &online_users {
442 assign_color(u, &mut color_map);
443 }
444 }
445 _ => {}
446 }
447 messages.push(msg);
448 }
449 Err(mpsc::error::TryRecvError::Empty) => break,
450 Err(mpsc::error::TryRecvError::Disconnected) => break 'main,
451 }
452 }
453
454 frame_count = frame_count.wrapping_add(1);
455 }
456
457 disable_raw_mode()?;
458 execute!(
459 terminal.backend_mut(),
460 DisableBracketedPaste,
461 LeaveAlternateScreen
462 )?;
463 terminal.show_cursor()?;
464
465 result
466}