Skip to main content

room_cli/tui/
event_loop.rs

1use std::collections::HashMap;
2use std::io;
3
4#[cfg(unix)]
5use std::os::unix::io::AsRawFd;
6
7use crossterm::{
8    event::{self, DisableBracketedPaste, EnableBracketedPaste, Event},
9    execute,
10    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
11};
12use ratatui::{
13    backend::CrosstermBackend,
14    layout::{Constraint, Direction, Layout, Rect},
15    Terminal,
16};
17use tokio::{
18    io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
19    sync::mpsc,
20};
21
22use super::dm::{handle_dm_action, switch_to_tab, DmTabConfig};
23use super::frame::{draw_frame, DrawContext};
24use super::input::{
25    build_payload, cursor_display_pos, handle_key, normalize_paste, wrap_input_display, Action,
26    InputState,
27};
28use super::parse::parse_users_all_broadcast;
29use super::render::{format_message, ColorMap, TabInfo};
30use super::{DrainResult, RoomTab, MAX_INPUT_LINES};
31use crate::message::Message;
32
33// ── Extracted helpers ────────────────────────────────────────────────────────
34
35/// Spawn a background task that reads from the broker socket, buffers history
36/// until the user's own join event, then streams live messages through the
37/// returned channel.
38fn setup_socket_reader(
39    reader: BufReader<tokio::net::unix::OwnedReadHalf>,
40    username: String,
41    history_lines: usize,
42) -> mpsc::UnboundedReceiver<Message> {
43    let (msg_tx, msg_rx) = mpsc::unbounded_channel::<Message>();
44
45    tokio::spawn(async move {
46        let mut reader = reader;
47        let mut history_buf: Vec<Message> = Vec::new();
48        let mut joined = false;
49        let mut line = String::new();
50
51        loop {
52            line.clear();
53            match reader.read_line(&mut line).await {
54                Ok(0) => break,
55                Ok(_) => {
56                    let trimmed = line.trim();
57                    if trimmed.is_empty() {
58                        continue;
59                    }
60                    let Ok(msg) = serde_json::from_str::<Message>(trimmed) else {
61                        continue;
62                    };
63
64                    if joined {
65                        let _ = msg_tx.send(msg);
66                    } else {
67                        let is_own_join =
68                            matches!(&msg, Message::Join { user, .. } if user == &username);
69                        if is_own_join {
70                            joined = true;
71                            let start = history_buf.len().saturating_sub(history_lines);
72                            for h in history_buf.drain(start..) {
73                                let _ = msg_tx.send(h);
74                            }
75                            let _ = msg_tx.send(msg);
76                        } else {
77                            history_buf.push(msg);
78                        }
79                    }
80                }
81                Err(_) => break,
82            }
83        }
84    });
85
86    msg_rx
87}
88
89/// Pre-computed layout dimensions for a single frame.
90///
91/// All fields are derived from the terminal size and input state. This struct
92/// is cheap to create and makes the layout computation unit-testable.
93pub(super) struct LayoutMetrics {
94    pub(super) show_tab_bar: bool,
95    pub(super) constraints: Vec<Constraint>,
96    pub(super) input_content_width: usize,
97    pub(super) content_width: usize,
98    pub(super) msg_area_height: usize,
99    pub(super) input_display_rows: Vec<String>,
100    pub(super) visible_input_lines: usize,
101    pub(super) total_input_rows: usize,
102    pub(super) cursor_row: usize,
103    pub(super) cursor_col: usize,
104}
105
106/// Compute layout dimensions from the terminal area and current input state.
107///
108/// This is a pure function (aside from adjusting `input_state.input_row_scroll`
109/// to keep the cursor visible). The returned [`LayoutMetrics`] contains
110/// everything needed to build a [`DrawContext`] and handle events.
111pub(super) fn compute_layout_metrics(
112    term_area: Rect,
113    input_state: &mut InputState,
114    tab_count: usize,
115) -> LayoutMetrics {
116    let show_tab_bar = tab_count > 1;
117
118    // Input content width is terminal width minus the two border columns.
119    let input_content_width = term_area.width.saturating_sub(2) as usize;
120
121    // Compute wrapped display rows for the input and the cursor position within them.
122    let input_display_rows = wrap_input_display(&input_state.input, input_content_width);
123    let total_input_rows = input_display_rows.len();
124    let visible_input_lines = total_input_rows.min(MAX_INPUT_LINES);
125    // +2 for top and bottom borders; minimum 3 (1 content line + 2 borders).
126    let input_box_height = (visible_input_lines + 2) as u16;
127
128    let (cursor_row, cursor_col) = cursor_display_pos(
129        &input_state.input,
130        input_state.cursor_pos,
131        input_content_width,
132    );
133
134    // Adjust vertical scroll so the cursor stays visible.
135    if cursor_row < input_state.input_row_scroll {
136        input_state.input_row_scroll = cursor_row;
137    }
138    if visible_input_lines > 0 && cursor_row >= input_state.input_row_scroll + visible_input_lines {
139        input_state.input_row_scroll = cursor_row + 1 - visible_input_lines;
140    }
141
142    let content_width = term_area.width.saturating_sub(2) as usize;
143
144    // Build layout constraints: optional tab bar + message area + input box.
145    let constraints: Vec<Constraint> = if show_tab_bar {
146        vec![
147            Constraint::Length(1),
148            Constraint::Min(3),
149            Constraint::Length(input_box_height),
150        ]
151    } else {
152        vec![Constraint::Min(3), Constraint::Length(input_box_height)]
153    };
154
155    // Compute visible message lines by pre-computing the layout split.
156    let msg_area_height = {
157        let chunks = Layout::default()
158            .direction(Direction::Vertical)
159            .constraints(constraints.clone())
160            .split(Rect::new(0, 0, term_area.width, term_area.height));
161        let msg_chunk = if show_tab_bar { chunks[1] } else { chunks[0] };
162        msg_chunk.height.saturating_sub(2) as usize
163    };
164
165    LayoutMetrics {
166        show_tab_bar,
167        constraints,
168        input_content_width,
169        content_width,
170        msg_area_height,
171        input_display_rows,
172        visible_input_lines,
173        total_input_rows,
174        cursor_row,
175        cursor_col,
176    }
177}
178
179/// Outcome of processing a single terminal event.
180enum EventAction {
181    /// Continue the main loop.
182    Continue,
183    /// Exit the main loop (quit or disconnect).
184    Break,
185    /// Exit the main loop with an error.
186    Error(anyhow::Error),
187}
188
189/// Immutable per-session parameters passed to [`handle_event`].
190struct EventConfig<'a> {
191    daemon_users: &'a [String],
192    socket_path: &'a std::path::Path,
193    username: &'a str,
194    history_lines: usize,
195}
196
197/// Poll for a terminal event and dispatch it, returning the loop action.
198///
199/// This handles key presses (delegating to [`handle_key`]), paste events, and
200/// resize events. Tab switching and DM actions that require async I/O are
201/// handled inline.
202async fn handle_event(
203    tabs: &mut Vec<RoomTab>,
204    active_tab: &mut usize,
205    input_state: &mut InputState,
206    msg_area_height: usize,
207    input_content_width: usize,
208    cfg: &EventConfig<'_>,
209) -> std::io::Result<EventAction> {
210    if !event::poll(std::time::Duration::from_millis(50))? {
211        return Ok(EventAction::Continue);
212    }
213
214    match event::read()? {
215        Event::Key(key) => {
216            let online_users = &tabs[*active_tab].online_users;
217            match handle_key(
218                key,
219                input_state,
220                online_users,
221                cfg.daemon_users,
222                msg_area_height,
223                input_content_width,
224            ) {
225                Some(Action::Send(payload)) => {
226                    if let Err(e) = tabs[*active_tab]
227                        .write_half
228                        .write_all(format!("{payload}\n").as_bytes())
229                        .await
230                    {
231                        return Ok(EventAction::Error(e.into()));
232                    }
233                }
234                Some(Action::Quit) => return Ok(EventAction::Break),
235                Some(Action::NextTab) => {
236                    if tabs.len() > 1 {
237                        let next = (*active_tab + 1) % tabs.len();
238                        switch_to_tab(tabs, active_tab, input_state, next);
239                    }
240                }
241                Some(Action::PrevTab) => {
242                    if tabs.len() > 1 {
243                        let prev = if *active_tab == 0 {
244                            tabs.len() - 1
245                        } else {
246                            *active_tab - 1
247                        };
248                        switch_to_tab(tabs, active_tab, input_state, prev);
249                    }
250                }
251                Some(Action::SwitchTab(idx)) => {
252                    if idx < tabs.len() {
253                        switch_to_tab(tabs, active_tab, input_state, idx);
254                    }
255                }
256                Some(Action::DmRoom {
257                    target_user,
258                    content,
259                }) => {
260                    let dm_cfg = DmTabConfig {
261                        socket_path: cfg.socket_path,
262                        username: cfg.username,
263                        history_lines: cfg.history_lines,
264                    };
265                    if let Err(e) = handle_dm_action(
266                        tabs,
267                        active_tab,
268                        input_state,
269                        &dm_cfg,
270                        target_user,
271                        content,
272                    )
273                    .await
274                    {
275                        return Ok(EventAction::Error(e));
276                    }
277                }
278                None => {}
279            }
280        }
281        Event::Paste(text) => {
282            let clean = normalize_paste(&text);
283            input_state.input.insert_str(input_state.cursor_pos, &clean);
284            input_state.cursor_pos += clean.len();
285            input_state.mention.active = false;
286        }
287        Event::Resize(_, _) => {}
288        _ => {}
289    }
290
291    Ok(EventAction::Continue)
292}
293
294// ── Main entry point ─────────────────────────────────────────────────────────
295
296pub async fn run(
297    reader: BufReader<tokio::net::unix::OwnedReadHalf>,
298    write_half: tokio::net::unix::OwnedWriteHalf,
299    room_id: &str,
300    username: &str,
301    history_lines: usize,
302    socket_path: std::path::PathBuf,
303) -> anyhow::Result<()> {
304    let msg_rx = setup_socket_reader(reader, username.to_owned(), history_lines);
305
306    let tab = RoomTab {
307        room_id: room_id.to_owned(),
308        messages: Vec::new(),
309        online_users: Vec::new(),
310        user_statuses: HashMap::new(),
311        subscription_tiers: HashMap::new(),
312        unread_count: 0,
313        scroll_offset: 0,
314        msg_rx,
315        write_half,
316    };
317
318    #[cfg(unix)]
319    let saved_stderr_fd = redirect_stderr_to_log();
320
321    enable_raw_mode()?;
322    let mut stdout = io::stdout();
323    execute!(stdout, EnterAlternateScreen, EnableBracketedPaste)?;
324    let backend = CrosstermBackend::new(stdout);
325    let mut terminal = Terminal::new(backend)?;
326
327    let mut tabs: Vec<RoomTab> = vec![tab];
328    let mut active_tab: usize = 0;
329    let mut color_map = ColorMap::new();
330    let mut input_state = InputState::new();
331    let mut daemon_users: Vec<String> = Vec::new();
332    let mut result: anyhow::Result<()> = Ok(());
333    let mut frame_count: usize = 0;
334
335    let splash_seed = std::time::SystemTime::now()
336        .duration_since(std::time::UNIX_EPOCH)
337        .map(|d| {
338            d.as_secs()
339                .wrapping_mul(6364136223846793005)
340                .wrapping_add(d.subsec_nanos() as u64)
341        })
342        .unwrap_or(0xdeadbeef_cafebabe);
343
344    // Seed online_users immediately so @mention autocomplete works for users
345    // who were already connected before we joined.
346    let who_payload = build_payload("/who");
347    tabs[active_tab]
348        .write_half
349        .write_all(format!("{who_payload}\n").as_bytes())
350        .await?;
351
352    // Seed daemon_users for cross-room @mention autocomplete.
353    let who_all_payload = build_payload("/who_all");
354    tabs[active_tab]
355        .write_half
356        .write_all(format!("{who_all_payload}\n").as_bytes())
357        .await?;
358
359    'main: loop {
360        // Sync scroll_offset: handle_key modifies input_state.scroll_offset,
361        // but rendering reads from tabs[active_tab].scroll_offset.
362        tabs[active_tab].scroll_offset = input_state.scroll_offset;
363
364        // Drain pending messages from all tabs.
365        for (i, t) in tabs.iter_mut().enumerate() {
366            let is_active = i == active_tab;
367            if matches!(
368                t.drain_messages(&mut color_map, is_active),
369                DrainResult::Disconnected
370            ) && is_active
371            {
372                break 'main;
373            }
374        }
375
376        // Check for users_all: response from /who_all to populate cross-room users.
377        for msg in tabs[active_tab].messages.iter().rev().take(5) {
378            if let Message::System { user, content, .. } = msg {
379                if user == "broker" {
380                    if let Some(users) = parse_users_all_broadcast(content) {
381                        daemon_users = users;
382                        break;
383                    }
384                }
385            }
386        }
387
388        let metrics = compute_layout_metrics(terminal.size()?.into(), &mut input_state, tabs.len());
389
390        // Format messages and compute scroll bounds.
391        let msg_texts: Vec<ratatui::text::Text<'static>> = tabs[active_tab]
392            .messages
393            .iter()
394            .map(|m| format_message(m, metrics.content_width, &color_map))
395            .collect();
396        let heights: Vec<usize> = msg_texts.iter().map(|t| t.lines.len().max(1)).collect();
397        let total_lines: usize = heights.iter().sum();
398
399        // Clamp scroll offset so it can't exceed scrollable range.
400        tabs[active_tab].scroll_offset = tabs[active_tab]
401            .scroll_offset
402            .min(total_lines.saturating_sub(metrics.msg_area_height));
403        input_state.scroll_offset = tabs[active_tab].scroll_offset;
404
405        let scroll_offset = tabs[active_tab].scroll_offset;
406        let room_id_display = tabs[active_tab].room_id.clone();
407        let tab_infos: Vec<TabInfo> = tabs
408            .iter()
409            .enumerate()
410            .map(|(i, t)| TabInfo {
411                room_id: t.room_id.clone(),
412                active: i == active_tab,
413                unread: t.unread_count,
414            })
415            .collect();
416
417        let ctx = DrawContext {
418            constraints: &metrics.constraints,
419            show_tab_bar: metrics.show_tab_bar,
420            tab_infos: &tab_infos,
421            msg_texts: &msg_texts,
422            heights: &heights,
423            total_lines,
424            scroll_offset,
425            msg_area_height: metrics.msg_area_height,
426            room_id_display: &room_id_display,
427            messages: &tabs[active_tab].messages,
428            online_users: &tabs[active_tab].online_users,
429            user_statuses: &tabs[active_tab].user_statuses,
430            subscription_tiers: &tabs[active_tab].subscription_tiers,
431            color_map: &color_map,
432            input_state: &input_state,
433            input_display_rows: &metrics.input_display_rows,
434            visible_input_lines: metrics.visible_input_lines,
435            total_input_rows: metrics.total_input_rows,
436            cursor_row: metrics.cursor_row,
437            cursor_col: metrics.cursor_col,
438            username,
439            frame_count,
440            splash_seed,
441        };
442
443        terminal.draw(|f| draw_frame(f, &ctx))?;
444
445        let event_cfg = EventConfig {
446            daemon_users: &daemon_users,
447            socket_path: &socket_path,
448            username,
449            history_lines,
450        };
451        match handle_event(
452            &mut tabs,
453            &mut active_tab,
454            &mut input_state,
455            metrics.msg_area_height,
456            metrics.input_content_width,
457            &event_cfg,
458        )
459        .await?
460        {
461            EventAction::Continue => {}
462            EventAction::Break => break 'main,
463            EventAction::Error(e) => {
464                result = Err(e);
465                break 'main;
466            }
467        }
468
469        // Drain any messages that arrived during the poll (all tabs).
470        for (i, t) in tabs.iter_mut().enumerate() {
471            let is_active = i == active_tab;
472            if matches!(
473                t.drain_messages(&mut color_map, is_active),
474                DrainResult::Disconnected
475            ) && is_active
476            {
477                break 'main;
478            }
479        }
480
481        frame_count = frame_count.wrapping_add(1);
482    }
483
484    disable_raw_mode()?;
485    execute!(
486        terminal.backend_mut(),
487        DisableBracketedPaste,
488        LeaveAlternateScreen
489    )?;
490    terminal.show_cursor()?;
491
492    #[cfg(unix)]
493    restore_stderr(saved_stderr_fd);
494
495    result
496}
497
498// ── Stderr redirection ───────────────────────────────────────────────────────
499
500/// Redirect stderr (fd 2) to `~/.room/room.log` so that `eprintln!` output
501/// from the broker does not corrupt the TUI alternate screen. Returns the
502/// saved fd so it can be restored after the TUI exits.
503#[cfg(unix)]
504fn redirect_stderr_to_log() -> Option<i32> {
505    let log_path = crate::paths::room_home().join("room.log");
506
507    let file = match std::fs::OpenOptions::new()
508        .create(true)
509        .append(true)
510        .open(&log_path)
511    {
512        Ok(f) => f,
513        Err(_) => return None,
514    };
515
516    // Save the current stderr fd so we can restore it later.
517    let saved = unsafe { libc::dup(libc::STDERR_FILENO) };
518    if saved < 0 {
519        return None;
520    }
521
522    let log_fd = file.as_raw_fd();
523    if unsafe { libc::dup2(log_fd, libc::STDERR_FILENO) } < 0 {
524        unsafe { libc::close(saved) };
525        return None;
526    }
527
528    Some(saved)
529}
530
531/// Restore stderr to its original fd after leaving the TUI.
532#[cfg(unix)]
533fn restore_stderr(saved: Option<i32>) {
534    if let Some(fd) = saved {
535        unsafe {
536            libc::dup2(fd, libc::STDERR_FILENO);
537            libc::close(fd);
538        }
539    }
540}
541
542// ── Tests ────────────────────────────────────────────────────────────────────
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547    use ratatui::layout::Rect;
548
549    #[test]
550    fn layout_metrics_single_tab() {
551        let mut input_state = InputState::new();
552        let term = Rect::new(0, 0, 80, 24);
553        let metrics = compute_layout_metrics(term, &mut input_state, 1);
554
555        assert!(!metrics.show_tab_bar);
556        // Single tab: 2 constraints (message area + input box).
557        assert_eq!(metrics.constraints.len(), 2);
558        assert_eq!(metrics.input_content_width, 78);
559        assert_eq!(metrics.content_width, 78);
560        // With a 24-row terminal, 3-row input box (1 line + 2 borders), message
561        // area gets 24 - 3 = 21 rows, minus 2 borders = 19 visible lines.
562        assert_eq!(metrics.msg_area_height, 19);
563    }
564
565    #[test]
566    fn layout_metrics_multi_tab() {
567        let mut input_state = InputState::new();
568        let term = Rect::new(0, 0, 80, 24);
569        let metrics = compute_layout_metrics(term, &mut input_state, 3);
570
571        assert!(metrics.show_tab_bar);
572        // Multi-tab: 3 constraints (tab bar + message area + input box).
573        assert_eq!(metrics.constraints.len(), 3);
574        // Tab bar takes 1 row, so message area = 24 - 1 - 3 = 20, minus 2 = 18.
575        assert_eq!(metrics.msg_area_height, 18);
576    }
577
578    #[test]
579    fn layout_metrics_narrow_terminal() {
580        let mut input_state = InputState::new();
581        let term = Rect::new(0, 0, 20, 10);
582        let metrics = compute_layout_metrics(term, &mut input_state, 1);
583
584        assert_eq!(metrics.input_content_width, 18);
585        assert_eq!(metrics.content_width, 18);
586    }
587
588    #[test]
589    fn layout_metrics_cursor_scroll_down() {
590        let mut input_state = InputState::new();
591        // Simulate a multi-line input that exceeds MAX_INPUT_LINES.
592        input_state.input = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8".into();
593        input_state.cursor_pos = input_state.input.len(); // cursor at end
594
595        let term = Rect::new(0, 0, 80, 24);
596        let metrics = compute_layout_metrics(term, &mut input_state, 1);
597
598        // With 8 lines of input, visible_input_lines is capped at MAX_INPUT_LINES (6).
599        assert_eq!(metrics.visible_input_lines, 6);
600        // input_row_scroll should have been adjusted so the cursor is visible.
601        assert!(metrics.cursor_row < input_state.input_row_scroll + metrics.visible_input_lines);
602    }
603
604    #[test]
605    fn layout_metrics_cursor_scroll_up() {
606        let mut input_state = InputState::new();
607        input_state.input = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8".into();
608        input_state.cursor_pos = 0; // cursor at start
609        input_state.input_row_scroll = 5; // scrolled past cursor
610
611        let term = Rect::new(0, 0, 80, 24);
612        let _metrics = compute_layout_metrics(term, &mut input_state, 1);
613
614        // Scroll should have been adjusted back so cursor (row 0) is visible.
615        assert_eq!(input_state.input_row_scroll, 0);
616    }
617
618    #[test]
619    fn layout_metrics_empty_input() {
620        let mut input_state = InputState::new();
621        let term = Rect::new(0, 0, 80, 24);
622        let metrics = compute_layout_metrics(term, &mut input_state, 1);
623
624        assert_eq!(metrics.cursor_row, 0);
625        assert_eq!(metrics.cursor_col, 0);
626        assert_eq!(metrics.visible_input_lines, 1);
627        assert_eq!(metrics.total_input_rows, 1);
628    }
629
630    #[test]
631    fn layout_metrics_minimum_terminal() {
632        let mut input_state = InputState::new();
633        // Very small terminal — 10x5.
634        let term = Rect::new(0, 0, 10, 5);
635        let metrics = compute_layout_metrics(term, &mut input_state, 1);
636
637        assert_eq!(metrics.input_content_width, 8);
638        // 5 rows - 3 (input box) = 2, minus 2 borders = 0. But Layout::Min(3)
639        // guarantees at least 3 rows for the message area, so height = 3 - 2 = 1.
640        assert_eq!(metrics.msg_area_height, 1);
641    }
642}