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::{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
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 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    // Seed online_users immediately so @mention autocomplete works for users
104    // who were already connected before we joined.
105    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        // Drain pending messages from the socket reader.
112        // Break 'main when the broker disconnects (sender dropped).
113        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                        // Seed from message senders so @mention works for users who connected
125                        // via poll/send (no persistent connection, not in the status_map).
126                        Message::Message { user, .. } if !online_users.contains(user) => {
127                            assign_color(user, &mut color_map);
128                            online_users.push(user.clone());
129                        }
130                        // Assign color for any message sender not yet in the map.
131                        Message::Message { user, .. } => {
132                            assign_color(user, &mut color_map);
133                        }
134                        // Parse the /who response to seed the authoritative user list.
135                        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        // Input content width is terminal width minus the two border columns.
152        let input_content_width = term_area.width.saturating_sub(2) as usize;
153
154        // Compute wrapped display rows for the input and the cursor position within them.
155        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        // +2 for top and bottom borders; minimum 3 (1 content line + 2 borders).
159        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        // Adjust vertical scroll so the cursor stays visible.
165        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        // Compute visible message lines by pre-computing the layout split.
174        // This must match the Layout used in the draw closure so scroll
175        // clamping and viewport computation use the same height.
176        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        // Clamp scroll offset so it can't exceed scrollable range
193        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            // Show the welcome splash when there are no chat messages yet.
227            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            // Render only the visible slice of wrapped input rows.
260            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            // Place terminal cursor inside the input box.
274            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            // Render the command palette popup above the input box when active.
280            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            // Render the mention picker popup above the cursor when active.
333            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) // '@' + username
359                    .max()
360                    .unwrap_or(8) as u16
361                    + 4; // borders + padding
362                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                    // Normalize line endings: \r\n → \n, stray \r → \n.
409                    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        // Drain any messages that arrived during the poll.
420        // Break 'main when the broker disconnects (sender dropped).
421        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}