Skip to main content

room_cli/tui/
mod.rs

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
32/// Maximum visible content lines in the input box before it stops growing.
33const 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    // Spawn socket-reader task: buffers history until our join event,
46    // then streams live messages.
47    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                            // Flush last N history entries then the join event
74                            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    // Setup terminal
90    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    // Seed online_users immediately so @mention autocomplete works for users
106    // who were already connected before we joined.
107    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        // Drain pending messages from the socket reader.
114        // Break 'main when the broker disconnects (sender dropped).
115        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                        // Seed from message senders so @mention works for users who connected
128                        // via poll/send (no persistent connection, not in the status_map).
129                        Message::Message { user, .. } if !online_users.contains(user) => {
130                            assign_color(user, &mut color_map);
131                            online_users.push(user.clone());
132                        }
133                        // Assign color for any message sender not yet in the map.
134                        Message::Message { user, .. } => {
135                            assign_color(user, &mut color_map);
136                        }
137                        // Parse the /who response to seed the authoritative user list.
138                        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        // Input content width is terminal width minus the two border columns.
166        let input_content_width = term_area.width.saturating_sub(2) as usize;
167
168        // Compute wrapped display rows for the input and the cursor position within them.
169        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        // +2 for top and bottom borders; minimum 3 (1 content line + 2 borders).
173        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        // Adjust vertical scroll so the cursor stays visible.
179        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        // Compute visible message lines by pre-computing the layout split.
188        // This must match the Layout used in the draw closure so scroll
189        // clamping and viewport computation use the same height.
190        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        // Clamp scroll offset so it can't exceed scrollable range
207        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            // Show the welcome splash when there are no chat messages yet.
241            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            // Render only the visible slice of wrapped input rows.
279            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            // Place terminal cursor inside the input box.
293            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            // Render floating member status panel (top-right of message area).
299            // Hidden when terminal is too narrow (< 80 cols) or no users online.
300            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 // "  " + status
331                        };
332                        u.len() + 1 + status_len + 1 // " " + name + status_part + " "
333                    })
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            // Render the command palette popup above the input box when active.
363            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            // Render the mention picker popup above the cursor when active.
416            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) // '@' + username
442                    .max()
443                    .unwrap_or(8) as u16
444                    + 4; // borders + padding
445                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                    // Normalize line endings: \r\n → \n, stray \r → \n.
492                    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        // Drain any messages that arrived during the poll.
503        // Break 'main when the broker disconnects (sender dropped).
504        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}