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::{Alignment, 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, parse_kick_broadcast, parse_status_broadcast,
28 seed_online_users_from_who, wrap_input_display, 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 user_statuses: std::collections::HashMap<String, String> =
99 std::collections::HashMap::new();
100 let mut color_map = ColorMap::new();
101 let mut state = InputState::new();
102 let mut result: anyhow::Result<()> = Ok(());
103 let mut frame_count: usize = 0;
104
105 let who_payload = build_payload("/who");
108 write_half
109 .write_all(format!("{who_payload}\n").as_bytes())
110 .await?;
111
112 'main: loop {
113 loop {
116 match msg_rx.try_recv() {
117 Ok(msg) => {
118 match &msg {
119 Message::Join { user, .. } if !online_users.contains(user) => {
120 assign_color(user, &mut color_map);
121 online_users.push(user.clone());
122 }
123 Message::Leave { user, .. } => {
124 online_users.retain(|u| u != user);
125 user_statuses.remove(user);
126 }
127 Message::Message { user, .. } if !online_users.contains(user) => {
130 assign_color(user, &mut color_map);
131 online_users.push(user.clone());
132 }
133 Message::Message { user, .. } => {
135 assign_color(user, &mut color_map);
136 }
137 Message::System { user, content, .. } if user == "broker" => {
139 seed_online_users_from_who(
140 content,
141 &mut online_users,
142 &mut user_statuses,
143 );
144 if let Some((name, status)) = parse_status_broadcast(content) {
145 user_statuses.insert(name, status);
146 }
147 if let Some(kicked) = parse_kick_broadcast(content) {
148 online_users.retain(|u| u != kicked);
149 user_statuses.remove(kicked);
150 }
151 for u in &online_users {
152 assign_color(u, &mut color_map);
153 }
154 }
155 _ => {}
156 }
157 messages.push(msg);
158 }
159 Err(mpsc::error::TryRecvError::Empty) => break,
160 Err(mpsc::error::TryRecvError::Disconnected) => break 'main,
161 }
162 }
163
164 let term_area = terminal.size()?;
165 let input_content_width = term_area.width.saturating_sub(2) as usize;
167
168 let input_display_rows = wrap_input_display(&state.input, input_content_width);
170 let total_input_rows = input_display_rows.len();
171 let visible_input_lines = total_input_rows.min(MAX_INPUT_LINES);
172 let input_box_height = (visible_input_lines + 2) as u16;
174
175 let (cursor_row, cursor_col) =
176 cursor_display_pos(&state.input, state.cursor_pos, input_content_width);
177
178 if cursor_row < state.input_row_scroll {
180 state.input_row_scroll = cursor_row;
181 }
182 if visible_input_lines > 0 && cursor_row >= state.input_row_scroll + visible_input_lines {
183 state.input_row_scroll = cursor_row + 1 - visible_input_lines;
184 }
185
186 let content_width = term_area.width.saturating_sub(2) as usize;
187 let msg_area_height = {
191 let chunks = Layout::default()
192 .direction(Direction::Vertical)
193 .constraints([Constraint::Min(3), Constraint::Length(input_box_height)])
194 .split(Rect::new(0, 0, term_area.width, term_area.height));
195 chunks[0].height.saturating_sub(2) as usize
196 };
197
198 let msg_texts: Vec<Text<'static>> = messages
199 .iter()
200 .map(|m| format_message(m, content_width, &color_map))
201 .collect();
202
203 let heights: Vec<usize> = msg_texts.iter().map(|t| t.lines.len().max(1)).collect();
204 let total_lines: usize = heights.iter().sum();
205
206 state.scroll_offset = state
208 .scroll_offset
209 .min(total_lines.saturating_sub(msg_area_height));
210
211 terminal.draw(|f| {
212 let chunks = Layout::default()
213 .direction(Direction::Vertical)
214 .constraints([Constraint::Min(3), Constraint::Length(input_box_height)])
215 .split(f.area());
216
217 let view_bottom = total_lines.saturating_sub(state.scroll_offset);
218 let view_top = view_bottom.saturating_sub(msg_area_height);
219
220 let (start_msg_idx, skip_first) = find_view_start(&heights, view_top);
221
222 let visible: Vec<ListItem> = msg_texts[start_msg_idx..]
223 .iter()
224 .enumerate()
225 .map(|(i, text)| {
226 if i == 0 && skip_first > 0 {
227 ListItem::new(Text::from(text.lines[skip_first..].to_vec()))
228 } else {
229 ListItem::new(text.clone())
230 }
231 })
232 .collect();
233
234 let title = if state.scroll_offset > 0 {
235 format!(" {room_id} [↑ {} lines] ", state.scroll_offset)
236 } else {
237 format!(" {room_id} ")
238 };
239
240 let has_chat = messages.iter().any(|m| {
242 matches!(
243 m,
244 Message::Message { .. }
245 | Message::Reply { .. }
246 | Message::Command { .. }
247 | Message::DirectMessage { .. }
248 )
249 });
250
251 let version_title =
252 Line::from(format!(" v{} ", env!("CARGO_PKG_VERSION"))).alignment(Alignment::Right);
253
254 if !has_chat {
255 let splash_width = chunks[0].width.saturating_sub(2) as usize;
256 let splash = welcome_splash(frame_count, splash_width);
257 let splash_widget = Paragraph::new(splash)
258 .block(
259 Block::default()
260 .title(title.clone())
261 .title_top(version_title)
262 .borders(Borders::ALL)
263 .border_style(Style::default().fg(Color::DarkGray)),
264 )
265 .alignment(Alignment::Left);
266 f.render_widget(splash_widget, chunks[0]);
267 } else {
268 let msg_list = List::new(visible).block(
269 Block::default()
270 .title(title)
271 .title_top(version_title)
272 .borders(Borders::ALL)
273 .border_style(Style::default().fg(Color::DarkGray)),
274 );
275 f.render_widget(msg_list, chunks[0]);
276 }
277
278 let end = (state.input_row_scroll + visible_input_lines).min(total_input_rows);
280 let display_text = input_display_rows[state.input_row_scroll..end].join("\n");
281
282 let input_widget = Paragraph::new(display_text)
283 .block(
284 Block::default()
285 .title(format!(" {username} "))
286 .borders(Borders::ALL)
287 .border_style(Style::default().fg(Color::Cyan)),
288 )
289 .style(Style::default().fg(Color::White));
290 f.render_widget(input_widget, chunks[1]);
291
292 let visible_cursor_row = cursor_row - state.input_row_scroll;
294 let cursor_x = chunks[1].x + 1 + cursor_col as u16;
295 let cursor_y = chunks[1].y + 1 + visible_cursor_row as u16;
296 f.set_cursor_position((cursor_x, cursor_y));
297
298 const PANEL_MIN_TERM_WIDTH: u16 = 80;
301 if f.area().width >= PANEL_MIN_TERM_WIDTH && !online_users.is_empty() {
302 let panel_items: Vec<ListItem> = online_users
303 .iter()
304 .map(|u| {
305 let status = user_statuses.get(u).map(|s| s.as_str()).unwrap_or("");
306 let mut spans = vec![Span::styled(
307 format!(" {u}"),
308 Style::default()
309 .fg(user_color(u, &color_map))
310 .add_modifier(Modifier::BOLD),
311 )];
312 if !status.is_empty() {
313 spans.push(Span::styled(
314 format!(" {status}"),
315 Style::default().fg(Color::DarkGray),
316 ));
317 }
318 spans.push(Span::raw(" "));
319 ListItem::new(Line::from(spans))
320 })
321 .collect();
322
323 let panel_content_width = online_users
324 .iter()
325 .map(|u| {
326 let status = user_statuses.get(u).map(|s| s.as_str()).unwrap_or("");
327 let status_len = if status.is_empty() {
328 0
329 } else {
330 status.len() + 2 };
332 u.len() + 1 + status_len + 1 })
334 .max()
335 .unwrap_or(10);
336 let panel_width = (panel_content_width as u16 + 2)
337 .min(chunks[0].width / 3)
338 .max(12);
339 let panel_height =
340 (online_users.len() as u16 + 2).min(chunks[0].height.saturating_sub(1));
341
342 let panel_x = chunks[0].x + chunks[0].width - panel_width - 1;
343 let panel_y = chunks[0].y + 1;
344
345 let panel_rect = Rect {
346 x: panel_x,
347 y: panel_y,
348 width: panel_width,
349 height: panel_height,
350 };
351
352 f.render_widget(Clear, panel_rect);
353 let panel = List::new(panel_items).block(
354 Block::default()
355 .title(" members ")
356 .borders(Borders::ALL)
357 .border_style(Style::default().fg(Color::DarkGray)),
358 );
359 f.render_widget(panel, panel_rect);
360 }
361
362 if state.palette.active && !state.palette.filtered.is_empty() {
364 let palette_items: Vec<ListItem> = state
365 .palette
366 .filtered
367 .iter()
368 .enumerate()
369 .map(|(row, &idx)| {
370 let item = &state.palette.commands[idx];
371 let style = if row == state.palette.selected {
372 Style::default()
373 .fg(Color::Black)
374 .bg(Color::Cyan)
375 .add_modifier(Modifier::BOLD)
376 } else {
377 Style::default().fg(Color::White)
378 };
379 ListItem::new(Line::from(vec![
380 Span::styled(
381 format!("{:<16}", item.usage),
382 style.add_modifier(Modifier::BOLD),
383 ),
384 Span::styled(
385 format!(" {}", item.description),
386 if row == state.palette.selected {
387 Style::default().fg(Color::Black).bg(Color::Cyan)
388 } else {
389 Style::default().fg(Color::DarkGray)
390 },
391 ),
392 ]))
393 })
394 .collect();
395
396 let popup_height = (state.palette.filtered.len() as u16 + 2).min(chunks[0].height);
397 let popup_y = chunks[1].y.saturating_sub(popup_height);
398 let popup_rect = Rect {
399 x: chunks[1].x,
400 y: popup_y,
401 width: chunks[1].width,
402 height: popup_height,
403 };
404
405 f.render_widget(Clear, popup_rect);
406 let palette_list = List::new(palette_items).block(
407 Block::default()
408 .title(" commands ")
409 .borders(Borders::ALL)
410 .border_style(Style::default().fg(Color::Cyan)),
411 );
412 f.render_widget(palette_list, popup_rect);
413 }
414
415 if state.mention.active && !state.mention.filtered.is_empty() {
417 let mention_items: Vec<ListItem> = state
418 .mention
419 .filtered
420 .iter()
421 .enumerate()
422 .map(|(row, user)| {
423 let style = if row == state.mention.selected {
424 Style::default()
425 .fg(Color::Black)
426 .bg(user_color(user, &color_map))
427 .add_modifier(Modifier::BOLD)
428 } else {
429 Style::default().fg(user_color(user, &color_map))
430 };
431 ListItem::new(Line::from(Span::styled(format!("@{user}"), style)))
432 })
433 .collect();
434
435 let popup_height = (state.mention.filtered.len() as u16 + 2).min(chunks[0].height);
436 let popup_y = chunks[1].y.saturating_sub(popup_height);
437 let max_width = state
438 .mention
439 .filtered
440 .iter()
441 .map(|u| u.len() + 1) .max()
443 .unwrap_or(8) as u16
444 + 4; let popup_width = max_width.min(chunks[1].width / 2).max(8);
446 let popup_x = cursor_x
447 .saturating_sub(1)
448 .min(chunks[1].x + chunks[1].width.saturating_sub(popup_width));
449 let popup_rect = Rect {
450 x: popup_x,
451 y: popup_y,
452 width: popup_width,
453 height: popup_height,
454 };
455
456 f.render_widget(Clear, popup_rect);
457 let mention_list = List::new(mention_items).block(
458 Block::default()
459 .title(" @ ")
460 .borders(Borders::ALL)
461 .border_style(Style::default().fg(Color::Yellow)),
462 );
463 f.render_widget(mention_list, popup_rect);
464 }
465 })?;
466
467 if event::poll(std::time::Duration::from_millis(50))? {
468 match event::read()? {
469 Event::Key(key) => {
470 match handle_key(
471 key,
472 &mut state,
473 &online_users,
474 msg_area_height,
475 input_content_width,
476 ) {
477 Some(Action::Send(payload)) => {
478 if let Err(e) = write_half
479 .write_all(format!("{payload}\n").as_bytes())
480 .await
481 {
482 result = Err(e.into());
483 break 'main;
484 }
485 }
486 Some(Action::Quit) => break 'main,
487 None => {}
488 }
489 }
490 Event::Paste(text) => {
491 let clean = text.replace("\r\n", "\n").replace('\r', "\n");
493 state.input.insert_str(state.cursor_pos, &clean);
494 state.cursor_pos += clean.len();
495 state.mention.active = false;
496 }
497 Event::Resize(_, _) => {}
498 _ => {}
499 }
500 }
501
502 loop {
505 match msg_rx.try_recv() {
506 Ok(msg) => {
507 match &msg {
508 Message::Join { user, .. } if !online_users.contains(user) => {
509 assign_color(user, &mut color_map);
510 online_users.push(user.clone());
511 }
512 Message::Leave { user, .. } => {
513 online_users.retain(|u| u != user);
514 user_statuses.remove(user);
515 }
516 Message::Message { user, .. } if !online_users.contains(user) => {
517 assign_color(user, &mut color_map);
518 online_users.push(user.clone());
519 }
520 Message::Message { user, .. } => {
521 assign_color(user, &mut color_map);
522 }
523 Message::System { user, content, .. } if user == "broker" => {
524 seed_online_users_from_who(
525 content,
526 &mut online_users,
527 &mut user_statuses,
528 );
529 if let Some((name, status)) = parse_status_broadcast(content) {
530 user_statuses.insert(name, status);
531 }
532 if let Some(kicked) = parse_kick_broadcast(content) {
533 online_users.retain(|u| u != kicked);
534 user_statuses.remove(kicked);
535 }
536 for u in &online_users {
537 assign_color(u, &mut color_map);
538 }
539 }
540 _ => {}
541 }
542 messages.push(msg);
543 }
544 Err(mpsc::error::TryRecvError::Empty) => break,
545 Err(mpsc::error::TryRecvError::Disconnected) => break 'main,
546 }
547 }
548
549 frame_count = frame_count.wrapping_add(1);
550 }
551
552 disable_raw_mode()?;
553 execute!(
554 terminal.backend_mut(),
555 DisableBracketedPaste,
556 LeaveAlternateScreen
557 )?;
558 terminal.show_cursor()?;
559
560 result
561}