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, 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 let version_title =
238 Line::from(format!(" v{} ", env!("CARGO_PKG_VERSION"))).alignment(Alignment::Right);
239
240 if !has_chat {
241 let splash_width = chunks[0].width.saturating_sub(2) as usize;
242 let splash = welcome_splash(frame_count, splash_width);
243 let splash_widget = Paragraph::new(splash)
244 .block(
245 Block::default()
246 .title(title.clone())
247 .title_top(version_title)
248 .borders(Borders::ALL)
249 .border_style(Style::default().fg(Color::DarkGray)),
250 )
251 .alignment(Alignment::Left);
252 f.render_widget(splash_widget, chunks[0]);
253 } else {
254 let msg_list = List::new(visible).block(
255 Block::default()
256 .title(title)
257 .title_top(version_title)
258 .borders(Borders::ALL)
259 .border_style(Style::default().fg(Color::DarkGray)),
260 );
261 f.render_widget(msg_list, chunks[0]);
262 }
263
264 let end = (state.input_row_scroll + visible_input_lines).min(total_input_rows);
266 let display_text = input_display_rows[state.input_row_scroll..end].join("\n");
267
268 let input_widget = Paragraph::new(display_text)
269 .block(
270 Block::default()
271 .title(format!(" {username} "))
272 .borders(Borders::ALL)
273 .border_style(Style::default().fg(Color::Cyan)),
274 )
275 .style(Style::default().fg(Color::White));
276 f.render_widget(input_widget, chunks[1]);
277
278 let visible_cursor_row = cursor_row - state.input_row_scroll;
280 let cursor_x = chunks[1].x + 1 + cursor_col as u16;
281 let cursor_y = chunks[1].y + 1 + visible_cursor_row as u16;
282 f.set_cursor_position((cursor_x, cursor_y));
283
284 if state.palette.active && !state.palette.filtered.is_empty() {
286 let palette_items: Vec<ListItem> = state
287 .palette
288 .filtered
289 .iter()
290 .enumerate()
291 .map(|(row, &idx)| {
292 let item = &state.palette.commands[idx];
293 let style = if row == state.palette.selected {
294 Style::default()
295 .fg(Color::Black)
296 .bg(Color::Cyan)
297 .add_modifier(Modifier::BOLD)
298 } else {
299 Style::default().fg(Color::White)
300 };
301 ListItem::new(Line::from(vec![
302 Span::styled(
303 format!("{:<16}", item.usage),
304 style.add_modifier(Modifier::BOLD),
305 ),
306 Span::styled(
307 format!(" {}", item.description),
308 if row == state.palette.selected {
309 Style::default().fg(Color::Black).bg(Color::Cyan)
310 } else {
311 Style::default().fg(Color::DarkGray)
312 },
313 ),
314 ]))
315 })
316 .collect();
317
318 let popup_height = (state.palette.filtered.len() as u16 + 2).min(chunks[0].height);
319 let popup_y = chunks[1].y.saturating_sub(popup_height);
320 let popup_rect = Rect {
321 x: chunks[1].x,
322 y: popup_y,
323 width: chunks[1].width,
324 height: popup_height,
325 };
326
327 f.render_widget(Clear, popup_rect);
328 let palette_list = List::new(palette_items).block(
329 Block::default()
330 .title(" commands ")
331 .borders(Borders::ALL)
332 .border_style(Style::default().fg(Color::Cyan)),
333 );
334 f.render_widget(palette_list, popup_rect);
335 }
336
337 if state.mention.active && !state.mention.filtered.is_empty() {
339 let mention_items: Vec<ListItem> = state
340 .mention
341 .filtered
342 .iter()
343 .enumerate()
344 .map(|(row, user)| {
345 let style = if row == state.mention.selected {
346 Style::default()
347 .fg(Color::Black)
348 .bg(user_color(user, &color_map))
349 .add_modifier(Modifier::BOLD)
350 } else {
351 Style::default().fg(user_color(user, &color_map))
352 };
353 ListItem::new(Line::from(Span::styled(format!("@{user}"), style)))
354 })
355 .collect();
356
357 let popup_height = (state.mention.filtered.len() as u16 + 2).min(chunks[0].height);
358 let popup_y = chunks[1].y.saturating_sub(popup_height);
359 let max_width = state
360 .mention
361 .filtered
362 .iter()
363 .map(|u| u.len() + 1) .max()
365 .unwrap_or(8) as u16
366 + 4; let popup_width = max_width.min(chunks[1].width / 2).max(8);
368 let popup_x = cursor_x
369 .saturating_sub(1)
370 .min(chunks[1].x + chunks[1].width.saturating_sub(popup_width));
371 let popup_rect = Rect {
372 x: popup_x,
373 y: popup_y,
374 width: popup_width,
375 height: popup_height,
376 };
377
378 f.render_widget(Clear, popup_rect);
379 let mention_list = List::new(mention_items).block(
380 Block::default()
381 .title(" @ ")
382 .borders(Borders::ALL)
383 .border_style(Style::default().fg(Color::Yellow)),
384 );
385 f.render_widget(mention_list, popup_rect);
386 }
387 })?;
388
389 if event::poll(std::time::Duration::from_millis(50))? {
390 match event::read()? {
391 Event::Key(key) => {
392 match handle_key(
393 key,
394 &mut state,
395 &online_users,
396 msg_area_height,
397 input_content_width,
398 ) {
399 Some(Action::Send(payload)) => {
400 if let Err(e) = write_half
401 .write_all(format!("{payload}\n").as_bytes())
402 .await
403 {
404 result = Err(e.into());
405 break 'main;
406 }
407 }
408 Some(Action::Quit) => break 'main,
409 None => {}
410 }
411 }
412 Event::Paste(text) => {
413 let clean = text.replace("\r\n", "\n").replace('\r', "\n");
415 state.input.insert_str(state.cursor_pos, &clean);
416 state.cursor_pos += clean.len();
417 state.mention.active = false;
418 }
419 Event::Resize(_, _) => {}
420 _ => {}
421 }
422 }
423
424 loop {
427 match msg_rx.try_recv() {
428 Ok(msg) => {
429 match &msg {
430 Message::Join { user, .. } if !online_users.contains(user) => {
431 assign_color(user, &mut color_map);
432 online_users.push(user.clone());
433 }
434 Message::Leave { user, .. } => {
435 online_users.retain(|u| u != user);
436 }
437 Message::Message { user, .. } if !online_users.contains(user) => {
438 assign_color(user, &mut color_map);
439 online_users.push(user.clone());
440 }
441 Message::Message { user, .. } => {
442 assign_color(user, &mut color_map);
443 }
444 Message::System { user, content, .. } if user == "broker" => {
445 seed_online_users_from_who(content, &mut online_users);
446 for u in &online_users {
447 assign_color(u, &mut color_map);
448 }
449 }
450 _ => {}
451 }
452 messages.push(msg);
453 }
454 Err(mpsc::error::TryRecvError::Empty) => break,
455 Err(mpsc::error::TryRecvError::Disconnected) => break 'main,
456 }
457 }
458
459 frame_count = frame_count.wrapping_add(1);
460 }
461
462 disable_raw_mode()?;
463 execute!(
464 terminal.backend_mut(),
465 DisableBracketedPaste,
466 LeaveAlternateScreen
467 )?;
468 terminal.show_cursor()?;
469
470 result
471}