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, 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            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            // Render only the visible slice of wrapped input rows.
265            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            // Place terminal cursor inside the input box.
279            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            // Render the command palette popup above the input box when active.
285            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            // Render the mention picker popup above the cursor when active.
338            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) // '@' + username
364                    .max()
365                    .unwrap_or(8) as u16
366                    + 4; // borders + padding
367                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                    // Normalize line endings: \r\n → \n, stray \r → \n.
414                    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        // Drain any messages that arrived during the poll.
425        // Break 'main when the broker disconnects (sender dropped).
426        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}