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
33fn 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
89pub(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
106pub(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 let input_content_width = term_area.width.saturating_sub(2) as usize;
120
121 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 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 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 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 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
179enum EventAction {
181 Continue,
183 Break,
185 Error(anyhow::Error),
187}
188
189struct EventConfig<'a> {
191 daemon_users: &'a [String],
192 socket_path: &'a std::path::Path,
193 username: &'a str,
194 history_lines: usize,
195}
196
197async 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
294pub 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 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 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 tabs[active_tab].scroll_offset = input_state.scroll_offset;
363
364 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 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 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 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 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#[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 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#[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#[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 assert_eq!(metrics.constraints.len(), 2);
558 assert_eq!(metrics.input_content_width, 78);
559 assert_eq!(metrics.content_width, 78);
560 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 assert_eq!(metrics.constraints.len(), 3);
574 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 input_state.input = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8".into();
593 input_state.cursor_pos = input_state.input.len(); let term = Rect::new(0, 0, 80, 24);
596 let metrics = compute_layout_metrics(term, &mut input_state, 1);
597
598 assert_eq!(metrics.visible_input_lines, 6);
600 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; input_state.input_row_scroll = 5; let term = Rect::new(0, 0, 80, 24);
612 let _metrics = compute_layout_metrics(term, &mut input_state, 1);
613
614 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 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 assert_eq!(metrics.msg_area_height, 1);
641 }
642}