Skip to main content

term_session_client/
lib.rs

1mod remote_pane;
2
3pub use remote_pane::RemotePane;
4
5use std::io::{self, IsTerminal, Write, stdout};
6#[cfg(unix)]
7use std::os::unix::io::FromRawFd;
8use std::sync::atomic::{AtomicBool, AtomicU16, Ordering};
9use std::sync::{Arc, Mutex};
10use std::time::Duration;
11
12use crossterm::QueueableCommand;
13use crossterm::cursor::{Hide, Show};
14use crossterm::event::{DisableBracketedPaste, EnableBracketedPaste};
15use crossterm::terminal::{
16    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
17};
18use muxio_rpc_service_endpoint::RpcServiceEndpointInterface;
19use muxio_tokio_mpsc_adapter::ChannelCallerExt;
20use muxio_tokio_rpc_ipc_client::{RpcCallPrebuffered, RpcIpcClient, RpcServiceCallerInterface};
21use portable_pty::PtySize;
22use term_session_muxio_service_definitions::{
23    OnPtyResized, RpcMethodPrebuffered, STREAM_INPUT_METHOD_ID, SUBSCRIBE_OUTPUT_METHOD_ID, Spawn,
24};
25use term_wm_events::{Event, KeyKind, KeyModifiers, MouseEventKind};
26use term_wm_pty_engine::Pane;
27use term_wm_pty_engine::clipboard::{Clipboard, Osc52Extractor};
28use term_wm_pty_engine::input_encoding::{key_to_bytes, mouse_event_to_bytes};
29use term_wm_pty_engine::signal::install_sigint_handler;
30use vt100::{MouseProtocolEncoding, MouseProtocolMode, Parser, Screen};
31
32/// Redirect an OS-level file descriptor (stdout or stderr) into `tracing`.
33///
34/// macOS system frameworks (AppKit, NSPasteboard, etc.) often write debug
35/// output directly to FD 1 or 2.  When the terminal is in raw/alt-screen mode
36/// this junk leaks to the display.  This function creates a pipe, redirects
37/// the given FD into it, and spawns a background thread that feeds incoming
38/// lines into `tracing::info!` (stdout) or `tracing::error!` (stderr).
39#[cfg(unix)]
40pub fn redirect_fd_to_tracing(target_fd: libc::c_int, is_stderr: bool) -> std::io::Result<()> {
41    let mut fds: [libc::c_int; 2] = [0; 2];
42    unsafe {
43        if libc::pipe(fds.as_mut_ptr()) == -1 {
44            return Err(std::io::Error::last_os_error());
45        }
46        if libc::dup2(fds[1], target_fd) == -1 {
47            libc::close(fds[0]);
48            libc::close(fds[1]);
49            return Err(std::io::Error::last_os_error());
50        }
51        libc::close(fds[1]);
52    }
53    let read_fd = fds[0];
54    let name = if is_stderr {
55        "stderr-tracing"
56    } else {
57        "stdout-tracing"
58    };
59    std::thread::Builder::new()
60        .name(name.into())
61        .spawn(move || {
62            use std::io::BufRead;
63            let file = unsafe { std::fs::File::from_raw_fd(read_fd) };
64            let mut reader = std::io::BufReader::new(file);
65            let mut buf = Vec::new();
66            while reader.read_until(b'\n', &mut buf).unwrap_or(0) > 0 {
67                let text = String::from_utf8_lossy(&buf);
68                let trimmed = text.trim();
69                if !trimmed.is_empty() {
70                    if is_stderr {
71                        tracing::error!(target: "c_stderr", "{}", trimmed);
72                    } else {
73                        tracing::info!(target: "c_stdout", "{}", trimmed);
74                    }
75                }
76                buf.clear();
77            }
78        })?;
79    Ok(())
80}
81
82/// Disable Windows console "QuickEdit" mode so mouse clicks select nothing and
83/// never suspend the console's output. Best-effort: console-less sessions
84/// (CI, redirected stdio) simply no-op.
85#[cfg(windows)]
86fn disable_quick_edit() {
87    use windows_sys::Win32::System::Console::{
88        ENABLE_EXTENDED_FLAGS, ENABLE_QUICK_EDIT_MODE, GetConsoleMode, GetStdHandle,
89        STD_INPUT_HANDLE, SetConsoleMode,
90    };
91
92    unsafe {
93        let handle = GetStdHandle(STD_INPUT_HANDLE);
94        let mut mode: u32 = 0;
95        if GetConsoleMode(handle, &mut mode) != 0 {
96            let _ = SetConsoleMode(
97                handle,
98                (mode & !ENABLE_QUICK_EDIT_MODE) | ENABLE_EXTENDED_FLAGS,
99            );
100        }
101    }
102}
103
104/// Number of iterations to wait for initial PTY output.
105/// Windows ConPTY needs more time to initialize and flush its internal buffers.
106#[cfg(target_os = "windows")]
107const INITIAL_WAIT_ITERS: usize = 60;
108#[cfg(not(target_os = "windows"))]
109const INITIAL_WAIT_ITERS: usize = 20;
110
111/// Maximum buffered PTY output frames before backpressure kicks in.
112const PTY_OUTPUT_CHANNEL_CAPACITY: usize = 256;
113/// Maximum buffered clipboard events (human-driven, small capacity is fine).
114const CLIPBOARD_CHANNEL_CAPACITY: usize = 64;
115/// Maximum buffered input events (covers paste bursts without over-allocating).
116const INPUT_CHANNEL_CAPACITY: usize = 64;
117
118/// Number of trailing bytes retained to detect OSC 52 clipboard sequences
119/// that straddle chunk boundaries.  8 bytes is enough to hold the longest
120/// OSC 52 tail (the BEL terminator and preceding data).
121const PREV_TAIL_LEN: usize = 8;
122
123/// Sleep duration (ms) between iterations while waiting for initial PTY output.
124const INITIAL_WAIT_SLEEP_MS: u64 = 50;
125
126/// Crossterm input polling interval (ms).  Short enough for responsive
127/// input, long enough to keep CPU idle when nothing is happening.
128const INPUT_POLL_MS: u64 = 50;
129
130/// Sleep duration (ms) in the output-backpressure loop when the PTY
131/// output channel is saturated.
132const BACKPRESSURE_SLEEP_MS: u64 = 1;
133
134/// Extra allocation headroom for bracketed-paste wrapper sequences:
135/// 6 bytes for `\x1b[200~` + 6 bytes for `\x1b[201~`.
136const BRACKETED_PASTE_OVERHEAD: usize = 12;
137
138/// Rough per-cell ANSI byte multiplier for initial render-buffer capacity.
139const RENDER_BUF_CELL_MULTIPLIER: usize = 3;
140
141/// Initialize terminal for TUI mode: write startup escape sequences
142/// (alternate screen, hide cursor, bracketed paste, mouse capture) to
143/// the given writer, enable raw mode on stdin, and return a guard that
144/// restores the terminal on drop.
145///
146/// The writer parameter allows tests to capture the ANSI sequences
147/// without writing to a real terminal.
148pub fn init_terminal<W: Write>(mut writer: W) -> io::Result<TerminalGuard<W>> {
149    if std::io::stdin().is_terminal() {
150        enable_raw_mode()?;
151    }
152    writer.queue(EnterAlternateScreen)?;
153    writer.queue(Hide)?;
154    writer.queue(EnableBracketedPaste)?;
155    writer.queue(crossterm::event::EnableMouseCapture)?;
156    writer.flush()?;
157    Ok(TerminalGuard {
158        writer: Some(writer),
159    })
160}
161
162/// Guard that restores the terminal (leave alternate screen, show cursor,
163/// disable bracketed paste) when dropped.  Generic over `W` so tests can
164/// inject a `Vec<u8>` writer and verify the teardown sequences.
165pub struct TerminalGuard<W: Write = std::io::Stdout> {
166    writer: Option<W>,
167}
168
169impl<W: Write> Drop for TerminalGuard<W> {
170    fn drop(&mut self) {
171        if let Some(ref mut writer) = self.writer {
172            let _ = writer.queue(crossterm::event::DisableMouseCapture);
173            let _ = writer.queue(DisableBracketedPaste);
174            let _ = writer.queue(Show);
175            let _ = writer.queue(LeaveAlternateScreen);
176            if std::io::stdin().is_terminal() {
177                let _ = disable_raw_mode();
178            }
179            let _ = writer.flush();
180        }
181    }
182}
183
184/// Convert a crossterm event into a core Event for use in the event-driven loop.
185fn convert_crossterm_event(evt: crossterm::event::Event) -> Option<Event> {
186    term_wm_crossterm_adapter::try_translate_event(evt)
187}
188
189/// Two motion mouse events may be coalesced (keep only the latest position)
190/// only when both the event kind and modifier flags match.  Modifier changes
191/// mid-drag (Shift/Ctrl/Alt pressed or released) must be preserved — they
192/// signal state transitions that terminal applications rely on.
193fn is_coalescable_mouse(
194    a_kind: &MouseEventKind,
195    a_mod: &KeyModifiers,
196    b_kind: &MouseEventKind,
197    b_mod: &KeyModifiers,
198) -> bool {
199    if a_mod != b_mod {
200        return false;
201    }
202    match (a_kind, b_kind) {
203        (MouseEventKind::Moved, MouseEventKind::Moved) => true,
204        (MouseEventKind::Drag(btn1), MouseEventKind::Drag(btn2)) => btn1 == btn2,
205        _ => false,
206    }
207}
208
209/// Connect to a term-session-server and run the TUI viewer.
210///
211/// This function is synchronous. It creates a background tokio runtime
212/// for muxio IPC, then runs the synchronous crossterm event loop on the
213/// calling thread.
214pub fn run_session(socket_path: &str) -> io::Result<()> {
215    // Windows console hosts default to "QuickEdit" mode: clicking the window
216    // enters text-selection mode, during which the kernel suspends the
217    // process's console I/O until the selection is cleared (Esc). A stray
218    // click then looks exactly like a frozen terminal. Disable it up front.
219    #[cfg(windows)]
220    disable_quick_edit();
221
222    // Redirect stderr to tracing so macOS AppKit/NSPasteboard noise doesn't
223    // leak to the terminal display.  Best-effort: if it fails (non-Unix, etc.)
224    // the session still works, just without the noise suppression.
225    #[cfg(unix)]
226    let _ = redirect_fd_to_tracing(libc::STDERR_FILENO, true);
227
228    let rt =
229        tokio::runtime::Runtime::new().map_err(|e| io::Error::other(format!("runtime: {e}")))?;
230
231    // Connect via muxio IPC
232    let client: Arc<RpcIpcClient> = rt
233        .block_on(RpcIpcClient::new(socket_path))
234        .map_err(|e| io::Error::new(io::ErrorKind::ConnectionRefused, format!("{e:?}")))?;
235
236    // Atomic registers for server-initiated geometry changes (OnPtyResized).
237    // Initialised before Spawn::call() so the handler is registered before
238    // the server can send any notifications — prevents RpcMethodNotFound.
239    let server_cols = Arc::new(AtomicU16::new(0));
240    let server_rows = Arc::new(AtomicU16::new(0));
241    let resize_pending = Arc::new(AtomicBool::new(false));
242
243    {
244        let cols_ref = Arc::clone(&server_cols);
245        let rows_ref = Arc::clone(&server_rows);
246        let pending_ref = Arc::clone(&resize_pending);
247        rt.block_on(client.get_endpoint().register_prebuffered(
248            OnPtyResized::METHOD_ID,
249            move |payload, _ctx| {
250                let cols_ref = Arc::clone(&cols_ref);
251                let rows_ref = Arc::clone(&rows_ref);
252                let pending_ref = Arc::clone(&pending_ref);
253                async move {
254                    let (cols, rows) = OnPtyResized::decode_request(&payload)
255                        .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
256                    cols_ref.store(cols, Ordering::Relaxed);
257                    rows_ref.store(rows, Ordering::Relaxed);
258                    pending_ref.store(true, Ordering::Relaxed);
259                    OnPtyResized::encode_response(())
260                        .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
261                }
262            },
263        ))
264        .map_err(|e| io::Error::other(format!("register OnPtyResized: {e:?}")))?;
265    }
266
267    // Channels for raw PTY output bytes and clipboard text from the subscription stream.
268    // Using crossbeam so the main loop can block on both input and PTY output.
269    // Bounded to cap head-of-line queuing under burst load.
270    let (push_tx, push_rx) = crossbeam_channel::bounded::<Vec<u8>>(PTY_OUTPUT_CHANNEL_CAPACITY);
271    let (clip_tx, clip_rx) = crossbeam_channel::bounded::<String>(CLIPBOARD_CHANNEL_CAPACITY);
272
273    // Get terminal size
274    let (term_cols, term_rows) = crossterm::terminal::size()?;
275
276    let (_session_id, actual_cols, actual_rows) = rt.block_on(async {
277        Spawn::call(&*client, (None, term_cols, term_rows))
278            .await
279            .map_err(|e| io::Error::other(format!("spawn: {e:?}")))
280    })?;
281
282    // Open streaming channels for output subscription and input
283    let writer = rt.block_on(async {
284        // Subscribe to PTY output via the mpsc adapter.
285        // `reader` yields response chunks (raw PTY output bytes).
286        let (_, mut reader) = client
287            .open_channel(SUBSCRIBE_OUTPUT_METHOD_ID, 0)
288            .await
289            .map_err(|e| io::Error::other(format!("subscribe: {e:?}")))?;
290
291        // Forward raw PTY output chunks to push_tx.  Each chunk from the
292        // muxio stream is a complete message — no custom framing needed.
293        // Intercept OSC 52 clipboard sequences before the parser consumes them.
294        rt.spawn(async move {
295            let mut osc52 = Osc52Extractor::new();
296            let mut prev_tail: [u8; PREV_TAIL_LEN] = [0; PREV_TAIL_LEN];
297
298            while let Some(chunk) = reader.recv().await {
299                if let Ok(mut data) = chunk {
300                    if let Some(text) = osc52.push(&data, &prev_tail) {
301                        let _ = clip_tx.try_send(text);
302                    }
303
304                    let n = data.len();
305                    if n >= PREV_TAIL_LEN {
306                        prev_tail.copy_from_slice(&data[n - PREV_TAIL_LEN..n]);
307                    } else if n > 0 {
308                        prev_tail.rotate_left(n);
309                        prev_tail[PREV_TAIL_LEN - n..].copy_from_slice(&data[..n]);
310                    }
311
312                    // Non-blocking push; if saturated, sleep 1ms to allow
313                    // the main loop to drain the channel without CPU spinning.
314                    while let Err(crossbeam_channel::TrySendError::Full(pending)) =
315                        push_tx.try_send(data)
316                    {
317                        data = pending;
318                        tokio::time::sleep(Duration::from_millis(BACKPRESSURE_SLEEP_MS)).await;
319                    }
320                } else {
321                    break;
322                }
323            }
324        });
325
326        // Open streaming channel for PTY input.
327        // `writer` accepts keystroke bytes.
328        let (writer, _) = client
329            .open_channel(STREAM_INPUT_METHOD_ID, 0)
330            .await
331            .map_err(|e| io::Error::other(format!("stream input: {e:?}")))?;
332
333        Ok::<_, io::Error>(writer)
334    })?;
335
336    let input_writer = Box::new(move |data: &[u8]| -> io::Result<()> {
337        writer
338            .send(data.to_vec())
339            .map_err(|e| io::Error::other(e.to_string()))?;
340        Ok(())
341    });
342
343    let mut pane = RemotePane::new(
344        1u64,
345        Some(client.clone()),
346        rt.handle().clone(),
347        term_cols,
348        term_rows,
349        push_rx.clone(),
350        input_writer,
351    );
352
353    // Wait for initial output
354    for _ in 0..INITIAL_WAIT_ITERS {
355        pane.drain_pushes();
356        let parser = pane.shared_parser();
357        let parser = parser.lock().unwrap();
358        if !parser.screen().contents_formatted().is_empty() {
359            break;
360        }
361        drop(parser);
362        std::thread::sleep(Duration::from_millis(INITIAL_WAIT_SLEEP_MS));
363    }
364
365    // Resize local parser to server-constrained geometry
366    {
367        let parser = pane.shared_parser();
368        let mut parser_lk = parser.lock().unwrap();
369        let (cur_rows, cur_cols) = parser_lk.screen().size();
370        if actual_cols != cur_cols || actual_rows != cur_rows {
371            parser_lk.screen_mut().set_size(actual_rows, actual_cols);
372        }
373        drop(parser_lk);
374    }
375
376    // Pass one stdout handle to init_terminal for the startup sequences
377    // and TerminalGuard teardown; keep a second handle for rendering.
378    let _guard = init_terminal(stdout())?;
379    let mut out = stdout();
380
381    let mut clipboard = Clipboard::new();
382    let sigint = install_sigint_handler()?;
383
384    // Channel for crossterm input events from a background thread
385    let (input_tx, input_rx) = crossbeam_channel::bounded::<Event>(INPUT_CHANNEL_CAPACITY);
386
387    // Spawn background crossterm input thread.
388    // Uses poll(INPUT_POLL_MS) so the thread can detect disconnection and exit
389    // promptly when run_session terminates.
390    std::thread::Builder::new()
391        .name("crossterm-input".into())
392        .spawn(move || {
393            loop {
394                match crossterm::event::poll(Duration::from_millis(INPUT_POLL_MS)) {
395                    Ok(true) => {
396                        if let Ok(crossterm_evt) = crossterm::event::read()
397                            && let Some(e) = convert_crossterm_event(crossterm_evt)
398                            && input_tx.send(e).is_err()
399                        {
400                            break;
401                        }
402                    }
403                    Ok(false) => continue,
404                    Err(_) => break,
405                }
406            }
407        })
408        .map_err(|e| io::Error::other(format!("spawn input thread: {e}")))?;
409
410    // Initial full-frame render
411    {
412        let parser = pane.shared_parser();
413        let parser = parser.lock().unwrap();
414        let screen = parser.screen();
415        let (rows, cols) = screen.size();
416        render_frame(&mut out, screen, rows, cols, false)?;
417    }
418
419    let mut pending_input: Option<Event> = None;
420    loop {
421        let mut force_render = false;
422        let mut clear_display = false;
423
424        // Helper: synchronize parser geometry from server-driven resize signal.
425        // Returns true if geometry was actually updated.
426        let apply_pending_resize = |shared_parser: &Arc<Mutex<Parser>>| -> bool {
427            if resize_pending.swap(false, Ordering::Relaxed) {
428                let cols = server_cols.load(Ordering::Relaxed);
429                let rows = server_rows.load(Ordering::Relaxed);
430                if cols > 0 && rows > 0 {
431                    let mut parser_lk = shared_parser.lock().unwrap();
432                    let (cur_rows, cur_cols) = parser_lk.screen().size();
433                    if cur_cols != cols || cur_rows != rows {
434                        parser_lk.screen_mut().set_size(rows, cols);
435                        return true;
436                    }
437                }
438            }
439            false
440        };
441
442        // Site 1: Apply any pending resize that arrived before this iteration
443        let resized = apply_pending_resize(&pane.shared_parser());
444        force_render |= resized;
445        clear_display |= resized;
446
447        // Retrieve next input event (either buffered from previous coalescing
448        // pass or blocking on the input/PTY-output channel)
449        let input_event = if let Some(evt) = pending_input.take() {
450            Some(evt)
451        } else {
452            crossbeam_channel::select! {
453                recv(input_rx) -> msg => {
454                    match msg {
455                        Ok(evt) => Some(evt),
456                        Err(_) => return Err(io::Error::other("input thread died")),
457                    }
458                }
459                recv(push_rx) -> msg => {
460                    match msg {
461                        Ok(data) => {
462                            // Site 2: Apply pending resize before parsing PTY bytes
463                            // (prevents DECAWM auto-scroll row duplication when
464                            // geometry changed between entering select and receiving
465                            // push_rx data)
466                            let resized = apply_pending_resize(&pane.shared_parser());
467                            force_render |= resized;
468                            clear_display |= resized;
469
470                            // PTY output — process directly into parser
471                            let parser = pane.shared_parser();
472                            let mut parser = parser.lock().unwrap();
473                            parser.process(&data);
474                            None
475                        }
476                        Err(_) => {
477                            // push channel disconnected → will be detected
478                            // by drain_pushes() below
479                            None
480                        }
481                    }
482                }
483            }
484        };
485
486        // Drain any additional buffered PTY data
487        let has_new_data = pane.drain_pushes() || input_event.is_none();
488
489        // Drain clipboard
490        while let Ok(text) = clip_rx.try_recv() {
491            clipboard.set(&text);
492        }
493
494        // Handle SIGINT
495        if sigint.received() {
496            sigint.ack();
497            let _ = pane.write_bytes(&[0x03]);
498        }
499
500        // Handle the input event (if any)
501        if let Some(mut evt) = input_event {
502            // Coalesce rapid mouse motion (Moved / Drag) events currently in
503            // the channel buffer.  Only the latest position matters — discard
504            // intermediate positions.  Modifier changes and non-motion events
505            // break the coalescing loop so they are never lost or reordered.
506            if let Event::Mouse(ref mut mouse) = evt
507                && matches!(mouse.kind, MouseEventKind::Moved | MouseEventKind::Drag(_))
508            {
509                while let Ok(next_evt) = input_rx.try_recv() {
510                    match next_evt {
511                        Event::Mouse(ref next_mouse)
512                            if is_coalescable_mouse(
513                                &mouse.kind,
514                                &mouse.modifiers,
515                                &next_mouse.kind,
516                                &next_mouse.modifiers,
517                            ) =>
518                        {
519                            *mouse = *next_mouse;
520                        }
521                        other => {
522                            pending_input = Some(other);
523                            break;
524                        }
525                    }
526                }
527            }
528
529            match evt {
530                Event::Key(ref key)
531                    if key.kind == KeyKind::Press || key.kind == KeyKind::Repeat =>
532                {
533                    let bytes = key_to_bytes(key, false);
534                    if !bytes.is_empty() {
535                        let _ = pane.write_bytes(&bytes);
536                    }
537                }
538                Event::Mouse(ref mouse) => {
539                    let mouse_active = {
540                        let parser = pane.shared_parser();
541                        let parser = parser.lock().unwrap();
542                        parser.screen().mouse_protocol_mode() != MouseProtocolMode::None
543                    };
544                    if mouse_active {
545                        let bytes = mouse_event_to_bytes(mouse, MouseProtocolEncoding::Sgr);
546                        if !bytes.is_empty() {
547                            let _ = pane.write_bytes(&bytes);
548                        }
549                    }
550                }
551                Event::Resize(w, h) => {
552                    let size = PtySize {
553                        rows: h,
554                        cols: w,
555                        pixel_width: 0,
556                        pixel_height: 0,
557                    };
558                    if let Err(err) = pane.resize(size) {
559                        tracing::warn!(error = %err, "resize request failed on PTY pane");
560                    }
561                    force_render = true;
562                    clear_display = true;
563                }
564                Event::Paste(text) => {
565                    let mut wrapped = Vec::with_capacity(text.len() + BRACKETED_PASTE_OVERHEAD);
566                    wrapped.extend_from_slice(b"\x1b[200~");
567                    wrapped.extend_from_slice(text.as_bytes());
568                    wrapped.extend_from_slice(b"\x1b[201~");
569                    let _ = pane.write_bytes(&wrapped);
570                }
571                _ => {}
572            }
573        }
574
575        // Connection health — check after wakeup
576        if !client.is_connected() {
577            return Err(io::Error::other("connection to session server lost"));
578        }
579
580        // Full-frame explicit row-by-row render
581        if has_new_data || force_render {
582            let parser = pane.shared_parser();
583            let parser = parser.lock().unwrap();
584            let screen = parser.screen();
585            let (rows, cols) = screen.size();
586            render_frame(&mut out, screen, rows, cols, clear_display)?;
587        }
588
589        // Exit on session exit
590        if pane.has_exited() {
591            return Ok(());
592        }
593    }
594}
595
596#[derive(Default, PartialEq, Clone, Copy)]
597struct CellStyle {
598    fg: vt100::Color,
599    bg: vt100::Color,
600    bold: bool,
601    dim: bool,
602    italic: bool,
603    underline: bool,
604    inverse: bool,
605}
606
607impl CellStyle {
608    fn from_cell(cell: &vt100::Cell) -> Self {
609        Self {
610            fg: cell.fgcolor(),
611            bg: cell.bgcolor(),
612            bold: cell.bold(),
613            dim: cell.dim(),
614            italic: cell.italic(),
615            underline: cell.underline(),
616            inverse: cell.inverse(),
617        }
618    }
619}
620
621fn apply_sgr(out: &mut dyn Write, style: &CellStyle) -> io::Result<()> {
622    write!(out, "\x1b[0m")?;
623    if style.bold {
624        write!(out, "\x1b[1m")?;
625    }
626    if style.dim {
627        write!(out, "\x1b[2m")?;
628    }
629    if style.italic {
630        write!(out, "\x1b[3m")?;
631    }
632    if style.underline {
633        write!(out, "\x1b[4m")?;
634    }
635    if style.inverse {
636        write!(out, "\x1b[7m")?;
637    }
638    match style.fg {
639        vt100::Color::Idx(i) => write!(out, "\x1b[38;5;{}m", i)?,
640        vt100::Color::Rgb(r, g, b) => write!(out, "\x1b[38;2;{};{};{}m", r, g, b)?,
641        _ => {}
642    }
643    match style.bg {
644        vt100::Color::Idx(i) => write!(out, "\x1b[48;5;{}m", i)?,
645        vt100::Color::Rgb(r, g, b) => write!(out, "\x1b[48;2;{};{};{}m", r, g, b)?,
646        _ => {}
647    }
648    Ok(())
649}
650
651pub fn render_frame(
652    out: &mut dyn Write,
653    screen: &Screen,
654    rows: u16,
655    cols: u16,
656    clear_display: bool,
657) -> io::Result<()> {
658    let mut buf =
659        Vec::with_capacity((rows as usize) * (cols as usize) * RENDER_BUF_CELL_MULTIPLIER);
660    let mut active_style = CellStyle::default();
661
662    // Synchronized Output begin, hide cursor, reset attributes
663    buf.extend_from_slice(b"\x1b[?2026h\x1b[?25l\x1b[0m");
664    if clear_display {
665        buf.extend_from_slice(b"\x1b[2J");
666    }
667    buf.extend_from_slice(b"\x1b[?7l");
668
669    for row in 0..rows {
670        write!(buf, "\x1b[{};1H", row + 1)?;
671
672        let mut col: u16 = 0;
673        while col < cols {
674            // Compute cell width first to handle wide chars (CJK, emoji) that
675            // span multiple columns — checking col + width >= cols catches the
676            // right-edge case even when a wide char at cols-2 jumps past cols-1.
677            let cell_opt = screen.cell(row, col);
678            let contents = cell_opt.map_or("", |c| c.contents());
679            let width = if contents.is_empty() {
680                1
681            } else {
682                unicode_width::UnicodeWidthStr::width(contents).max(1) as u16
683            };
684
685            // Margin sanitation: clear right margin before writing the cell that
686            // touches or passes the right edge.  Placing \x1b[K here (while the
687            // cursor is still at col) avoids cursor-inclusive erasure of the cell.
688            if col + width >= cols {
689                buf.extend_from_slice(b"\x1b[0m\x1b[K");
690                active_style = CellStyle::default();
691            }
692
693            let style = cell_opt.map(CellStyle::from_cell).unwrap_or_default();
694            if style != active_style {
695                apply_sgr(&mut buf, &style)?;
696                active_style = style;
697            }
698
699            if contents.is_empty() {
700                buf.push(b' ');
701            } else {
702                buf.extend_from_slice(contents.as_bytes());
703            }
704
705            col += width;
706        }
707    }
708
709    buf.extend_from_slice(b"\x1b[?7h");
710    buf.extend_from_slice(b"\x1b[0m");
711    let (cur_row, cur_col) = screen.cursor_position();
712    write!(buf, "\x1b[{};{}H", cur_row + 1, cur_col + 1)?;
713    if screen.hide_cursor() {
714        buf.extend_from_slice(b"\x1b[?25l");
715    } else {
716        buf.extend_from_slice(b"\x1b[?25h");
717    }
718    // Synchronized Output end — terminal now paints atomically
719    buf.extend_from_slice(b"\x1b[?2026l");
720
721    out.write_all(&buf)?;
722    out.flush()
723}
724
725#[cfg(test)]
726mod tests {
727    use super::*;
728    use std::sync::{Arc, Mutex};
729
730    use term_wm_events::{KeyCode, KeyEvent, MouseButton, MouseEvent};
731
732    struct TestWriter {
733        buf: Arc<Mutex<Vec<u8>>>,
734    }
735
736    impl TestWriter {
737        fn new() -> (Self, Arc<Mutex<Vec<u8>>>) {
738            let buf = Arc::new(Mutex::new(Vec::new()));
739            (Self { buf: buf.clone() }, buf)
740        }
741    }
742
743    impl Write for TestWriter {
744        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
745            self.buf.lock().unwrap().extend_from_slice(buf);
746            Ok(buf.len())
747        }
748        fn flush(&mut self) -> io::Result<()> {
749            Ok(())
750        }
751    }
752
753    /// Calls the real `init_terminal()` with a test writer and verifies
754    /// the bracketed paste enable sequence `\x1b[?2004h` is written.
755    /// Under `cargo test` stdin is a pipe, so `is_terminal()` returns false
756    /// and the raw-mode OS call is skipped — only the ANSI output matters.
757    #[test]
758    fn init_terminal_writes_bracketed_paste_enable() {
759        let (writer, buf) = TestWriter::new();
760        let _guard = init_terminal(writer).expect("init_terminal");
761        let bytes = buf.lock().unwrap();
762        assert!(
763            bytes
764                .windows(b"\x1b[?2004h".len())
765                .any(|w| w == b"\x1b[?2004h")
766        );
767    }
768
769    /// Constructs a TerminalGuard with a test writer and verifies that
770    /// dropping it writes the bracketed paste disable sequence `\x1b[?2004l`.
771    #[test]
772    fn terminal_guard_teardown_writes_bracketed_paste_disable() {
773        let (writer, buf) = TestWriter::new();
774        {
775            let _guard = TerminalGuard {
776                writer: Some(writer),
777            };
778        }
779        let bytes = buf.lock().unwrap();
780        assert!(
781            bytes
782                .windows(b"\x1b[?2004l".len())
783                .any(|w| w == b"\x1b[?2004l")
784        );
785    }
786
787    /// Full lifecycle: init_terminal followed by TerminalGuard teardown
788    /// writes both the enable and disable sequences.
789    #[test]
790    fn init_and_teardown_roundtrip_contains_both_sequences() {
791        let (writer, buf) = TestWriter::new();
792        let guard = init_terminal(writer).expect("init_terminal");
793        drop(guard);
794        let bytes = buf.lock().unwrap();
795        assert!(
796            bytes
797                .windows(b"\x1b[?2004h".len())
798                .any(|w| w == b"\x1b[?2004h")
799        );
800        assert!(
801            bytes
802                .windows(b"\x1b[?2004l".len())
803                .any(|w| w == b"\x1b[?2004l")
804        );
805    }
806
807    /// Proves that reusing a parser via set_size + RIS + process yields
808    /// identical screen state to a freshly allocated parser.
809    #[test]
810    fn test_prev_parser_resize_sync_matches_fresh_parser() {
811        let mut prev_parser = vt100::Parser::new(24, 80, 0);
812        prev_parser.process(b"initial screen content");
813
814        // Simulate terminal window resize to 40x120
815        let (new_rows, new_cols) = (40, 120);
816        let new_formatted_content = {
817            let mut p = vt100::Parser::new(new_rows, new_cols, 0);
818            p.process(b"resized screen content");
819            p.screen().contents_formatted().to_vec()
820        };
821
822        // Re-use prev_parser using dimension sync + RIS reset
823        prev_parser.screen_mut().set_size(new_rows, new_cols);
824        prev_parser.process(b"\x1bc");
825        prev_parser.process(&new_formatted_content);
826
827        // Verify against a freshly created parser
828        let mut fresh_parser = vt100::Parser::new(new_rows, new_cols, 0);
829        fresh_parser.process(&new_formatted_content);
830
831        assert_eq!(
832            prev_parser.screen().contents_formatted(),
833            fresh_parser.screen().contents_formatted(),
834            "Reused parser state after set_size + RIS must match fresh parser"
835        );
836    }
837
838    #[test]
839    fn render_frame_outputs_correct_cup_and_sgr() {
840        let mut parser = vt100::Parser::new(4, 8, 0);
841        parser.process(b"\x1b[31mhello\x1b[0m");
842        let screen = parser.screen();
843        let mut buf: Vec<u8> = Vec::new();
844        let (rows, cols) = screen.size();
845        render_frame(&mut buf, screen, rows, cols, false).unwrap();
846        let output = String::from_utf8_lossy(&buf);
847        // Should contain CUP to each row (4 rows)
848        assert!(output.contains("\x1b[1;1H"));
849        assert!(output.contains("\x1b[2;1H"));
850        assert!(output.contains("\x1b[3;1H"));
851        assert!(output.contains("\x1b[4;1H"));
852        // Should contain "hello"
853        assert!(output.contains("hello"));
854        // Should contain red foreground SGR
855        assert!(
856            output.contains("\x1b[38;5;1m") || output.contains("\x1b[31m"),
857            "Expected red foreground SGR in output: {output:?}"
858        );
859        // Should not contain raw ESC characters without following sequences
860        assert!(!output.contains("\x1b\x1b"), "no double ESC sequences");
861    }
862
863    // ── is_coalescable_mouse tests ────────────────────────────────────────
864
865    #[test]
866    fn coalesce_moved_with_moved() {
867        assert!(is_coalescable_mouse(
868            &MouseEventKind::Moved,
869            &KeyModifiers::NONE,
870            &MouseEventKind::Moved,
871            &KeyModifiers::NONE,
872        ));
873    }
874
875    #[test]
876    fn coalesce_drag_same_button() {
877        assert!(is_coalescable_mouse(
878            &MouseEventKind::Drag(MouseButton::Left),
879            &KeyModifiers::NONE,
880            &MouseEventKind::Drag(MouseButton::Left),
881            &KeyModifiers::NONE,
882        ));
883        assert!(is_coalescable_mouse(
884            &MouseEventKind::Drag(MouseButton::Right),
885            &KeyModifiers {
886                shift: true,
887                ..KeyModifiers::NONE
888            },
889            &MouseEventKind::Drag(MouseButton::Right),
890            &KeyModifiers {
891                shift: true,
892                ..KeyModifiers::NONE
893            },
894        ));
895    }
896
897    #[test]
898    fn reject_drag_different_button() {
899        assert!(!is_coalescable_mouse(
900            &MouseEventKind::Drag(MouseButton::Left),
901            &KeyModifiers::NONE,
902            &MouseEventKind::Drag(MouseButton::Right),
903            &KeyModifiers::NONE,
904        ));
905    }
906
907    #[test]
908    fn reject_moved_vs_drag() {
909        assert!(!is_coalescable_mouse(
910            &MouseEventKind::Moved,
911            &KeyModifiers::NONE,
912            &MouseEventKind::Drag(MouseButton::Left),
913            &KeyModifiers::NONE,
914        ));
915    }
916
917    #[test]
918    fn reject_different_modifiers() {
919        assert!(!is_coalescable_mouse(
920            &MouseEventKind::Moved,
921            &KeyModifiers::NONE,
922            &MouseEventKind::Moved,
923            &KeyModifiers {
924                shift: true,
925                ..KeyModifiers::NONE
926            },
927        ));
928        assert!(!is_coalescable_mouse(
929            &MouseEventKind::Drag(MouseButton::Left),
930            &KeyModifiers {
931                control: true,
932                ..KeyModifiers::NONE
933            },
934            &MouseEventKind::Drag(MouseButton::Left),
935            &KeyModifiers::NONE,
936        ));
937    }
938
939    #[test]
940    fn reject_discrete_events() {
941        assert!(!is_coalescable_mouse(
942            &MouseEventKind::Press(MouseButton::Left),
943            &KeyModifiers::NONE,
944            &MouseEventKind::Press(MouseButton::Left),
945            &KeyModifiers::NONE,
946        ));
947        assert!(!is_coalescable_mouse(
948            &MouseEventKind::Release(MouseButton::Left),
949            &KeyModifiers::NONE,
950            &MouseEventKind::Moved,
951            &KeyModifiers::NONE,
952        ));
953        assert!(!is_coalescable_mouse(
954            &MouseEventKind::Moved,
955            &KeyModifiers::NONE,
956            &MouseEventKind::ScrollDown,
957            &KeyModifiers::NONE,
958        ));
959        assert!(!is_coalescable_mouse(
960            &MouseEventKind::ScrollUp,
961            &KeyModifiers::NONE,
962            &MouseEventKind::ScrollUp,
963            &KeyModifiers::NONE,
964        ));
965    }
966
967    // ── Coalescing loop integration tests ─────────────────────────────────
968
969    /// Helper: run the coalescing logic from the main loop against a real
970    /// bounded channel, returning the final event (or None if filtered away).
971    fn coalesce_through(
972        events: &[Event],
973        kind: MouseEventKind,
974        modifiers: KeyModifiers,
975    ) -> Option<Event> {
976        let (tx, rx) = crossbeam_channel::bounded::<Event>(events.len());
977        for e in events.iter().cloned() {
978            tx.send(e).ok();
979        }
980        drop(tx);
981
982        let mut result = Event::Mouse(MouseEvent {
983            kind,
984            modifiers,
985            column: 0,
986            row: 0,
987        });
988
989        if let Event::Mouse(ref mut mouse) = result
990            && matches!(mouse.kind, MouseEventKind::Moved | MouseEventKind::Drag(_))
991        {
992            while let Ok(next) = rx.try_recv() {
993                match next {
994                    Event::Mouse(ref next_mouse)
995                        if is_coalescable_mouse(
996                            &mouse.kind,
997                            &mouse.modifiers,
998                            &next_mouse.kind,
999                            &next_mouse.modifiers,
1000                        ) =>
1001                    {
1002                        *mouse = *next_mouse;
1003                    }
1004                    _other => return Some(result),
1005                }
1006            }
1007        }
1008
1009        Some(result)
1010    }
1011
1012    #[test]
1013    fn coalesce_keeps_latest_moved_position() {
1014        let events = vec![
1015            Event::Mouse(MouseEvent {
1016                kind: MouseEventKind::Moved,
1017                modifiers: KeyModifiers::NONE,
1018                column: 5,
1019                row: 5,
1020            }),
1021            Event::Mouse(MouseEvent {
1022                kind: MouseEventKind::Moved,
1023                modifiers: KeyModifiers::NONE,
1024                column: 10,
1025                row: 10,
1026            }),
1027            Event::Mouse(MouseEvent {
1028                kind: MouseEventKind::Moved,
1029                modifiers: KeyModifiers::NONE,
1030                column: 15,
1031                row: 15,
1032            }),
1033        ];
1034        let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1035        let Event::Mouse(m) = result.unwrap() else {
1036            panic!("expected mouse")
1037        };
1038        assert_eq!((m.column, m.row), (15, 15));
1039    }
1040
1041    #[test]
1042    fn coalesce_keeps_latest_drag_position() {
1043        let events = vec![
1044            Event::Mouse(MouseEvent {
1045                kind: MouseEventKind::Drag(MouseButton::Left),
1046                modifiers: KeyModifiers::NONE,
1047                column: 1,
1048                row: 1,
1049            }),
1050            Event::Mouse(MouseEvent {
1051                kind: MouseEventKind::Drag(MouseButton::Left),
1052                modifiers: KeyModifiers::NONE,
1053                column: 2,
1054                row: 2,
1055            }),
1056        ];
1057        let result = coalesce_through(
1058            &events,
1059            MouseEventKind::Drag(MouseButton::Left),
1060            KeyModifiers::NONE,
1061        );
1062        let Event::Mouse(m) = result.unwrap() else {
1063            panic!("expected mouse")
1064        };
1065        assert_eq!((m.column, m.row), (2, 2));
1066    }
1067
1068    #[test]
1069    fn coalesce_stops_at_modifier_change() {
1070        let events = vec![Event::Mouse(MouseEvent {
1071            kind: MouseEventKind::Moved,
1072            modifiers: KeyModifiers {
1073                shift: true,
1074                ..KeyModifiers::NONE
1075            },
1076            column: 99,
1077            row: 99,
1078        })];
1079        let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1080        let Event::Mouse(m) = result.unwrap() else {
1081            panic!("expected mouse")
1082        };
1083        // The first event (modifier change) should NOT be consumed — we
1084        // still hold the original event at (0,0) with NONE modifiers.
1085        assert_eq!((m.column, m.row), (0, 0));
1086    }
1087
1088    #[test]
1089    fn coalesce_stops_at_non_mouse_event() {
1090        let key = Event::Key(KeyEvent {
1091            code: KeyCode::Char('q'),
1092            kind: KeyKind::Press,
1093            modifiers: KeyModifiers::NONE,
1094        });
1095        let events = vec![key.clone()];
1096        let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1097        let Event::Mouse(m) = result.unwrap() else {
1098            panic!("expected mouse")
1099        };
1100        // Should retain original event, not consuming the key
1101        assert_eq!((m.column, m.row), (0, 0));
1102    }
1103
1104    #[test]
1105    fn coalesce_stops_at_discrete_mouse_event() {
1106        let events = vec![Event::Mouse(MouseEvent {
1107            kind: MouseEventKind::Press(MouseButton::Left),
1108            modifiers: KeyModifiers::NONE,
1109            column: 10,
1110            row: 10,
1111        })];
1112        let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1113        let Event::Mouse(m) = result.unwrap() else {
1114            panic!("expected mouse")
1115        };
1116        // Should NOT consume the Press event
1117        assert_eq!((m.column, m.row), (0, 0));
1118    }
1119}
1120
1121// ── Snapshot tests for render_frame byte output ─────────────────────────
1122// Uses a push_rx mock (crossbeam channel) + RemotePane(client: None) for
1123// deterministic, non-flaky byte-stream assertions.
1124#[cfg(test)]
1125#[allow(clippy::type_complexity)]
1126mod snapshot_tests {
1127    use super::*;
1128
1129    /// Render a screen from deterministic PTY bytes, capturing the raw
1130    /// ANSI output.
1131    fn render_and_capture(pty_bytes: &[u8], rows: u16, cols: u16, clear_display: bool) -> Vec<u8> {
1132        let rt = tokio::runtime::Builder::new_current_thread()
1133            .build()
1134            .expect("tokio rt");
1135        let (push_tx, push_rx) = crossbeam_channel::bounded(16);
1136        let input_writer: Box<dyn FnMut(&[u8]) -> io::Result<()> + Send> = Box::new(|_| Ok(()));
1137        let mut pane = RemotePane::new(
1138            0,
1139            None,
1140            rt.handle().clone(),
1141            cols,
1142            rows,
1143            push_rx,
1144            input_writer,
1145        );
1146        drop(rt); // rt must outlive the channels but not RemotePane
1147
1148        push_tx.send(pty_bytes.to_vec()).ok();
1149        pane.drain_pushes();
1150
1151        let parser = pane.shared_parser();
1152        let parser = parser.lock().unwrap();
1153        let screen = parser.screen();
1154        let (rows, cols) = screen.size();
1155        let mut out = Vec::new();
1156        render_frame(&mut out, screen, rows, cols, clear_display).unwrap();
1157        out
1158    }
1159
1160    /// Escape ANSI and control bytes for readable snapshot diffs.
1161    fn escape_ansi(bytes: &[u8]) -> String {
1162        let mut out: Vec<u8> = Vec::with_capacity(bytes.len() * 4);
1163        for &b in bytes {
1164            match b {
1165                b'\x1b' => out.extend_from_slice(b"\\x1b"),
1166                b'\n' => out.extend_from_slice(b"\\n"),
1167                b'\r' => out.extend_from_slice(b"\\r"),
1168                b'\t' => out.extend_from_slice(b"\\t"),
1169                0x20..=0x7e => out.push(b),
1170                _ => {
1171                    out.push(b'\\');
1172                    out.push(b'x');
1173                    out.extend_from_slice(&hex_byte(b));
1174                }
1175            }
1176        }
1177        // SAFETY: all bytes are valid ASCII (0x20-0x7e or escaped sequences)
1178        unsafe { String::from_utf8_unchecked(out) }
1179    }
1180
1181    fn hex_byte(b: u8) -> [u8; 2] {
1182        #[inline]
1183        fn hex_nibble(n: u8) -> u8 {
1184            let digit = n & 0x0f;
1185            if digit < 10 {
1186                b'0' + digit
1187            } else {
1188                b'a' + digit - 10
1189            }
1190        }
1191        [hex_nibble(b >> 4), hex_nibble(b)]
1192    }
1193
1194    // ── Tests ────────────────────────────────────────────────────────
1195
1196    #[test]
1197    fn snapshot_empty_grid() {
1198        let out = render_and_capture(b"", 4, 8, false);
1199        insta::assert_snapshot!("empty_grid", escape_ansi(&out));
1200    }
1201
1202    #[test]
1203    fn snapshot_basic_text() {
1204        let out = render_and_capture(b"Hello\nWorld", 4, 8, false);
1205        insta::assert_snapshot!("basic_text", escape_ansi(&out));
1206    }
1207
1208    #[test]
1209    fn snapshot_colored_text() {
1210        let out = render_and_capture(b"\x1b[31mred\x1b[1mbold", 4, 8, false);
1211        insta::assert_snapshot!("colored_text", escape_ansi(&out));
1212    }
1213
1214    #[test]
1215    fn snapshot_normal_char_at_margin() {
1216        // Fill a 4-wide grid so the last column contains 'D' — triggers
1217        // margin sanitation before the final cell in the row.
1218        let out = render_and_capture(b"ABCD", 1, 4, false);
1219        insta::assert_snapshot!("normal_char_at_margin", escape_ansi(&out));
1220    }
1221
1222    #[test]
1223    fn snapshot_clear_display() {
1224        let out = render_and_capture(b"", 4, 8, true);
1225        insta::assert_snapshot!("clear_display", escape_ansi(&out));
1226    }
1227
1228    #[test]
1229    fn snapshot_hidden_cursor() {
1230        let out = render_and_capture(b"\x1b[?25l", 4, 8, false);
1231        insta::assert_snapshot!("hidden_cursor", escape_ansi(&out));
1232    }
1233
1234    #[test]
1235    fn snapshot_color_across_margin() {
1236        // Red background on a block char right at the last column.
1237        // Verify \x1b[0m resets the color before \x1b[K clears the margin.
1238        let out = render_and_capture(b"\x1b[41mX", 1, 4, false);
1239        insta::assert_snapshot!("color_across_margin", escape_ansi(&out));
1240    }
1241
1242    #[test]
1243    fn snapshot_multi_row_fill() {
1244        // Fill all 4×3 cells with unique chars to verify row-by-row CUP +
1245        // margin sanitation on every row.
1246        let out = render_and_capture(b"ABCDEFGHIJKL", 3, 4, false);
1247        insta::assert_snapshot!("multi_row_fill", escape_ansi(&out));
1248    }
1249
1250    #[test]
1251    fn snapshot_wide_char_margin() {
1252        // Use 3-wide grid with CJK at col 1 (width 2, fills cols 1-2).
1253        // Margin check: 1 + 2 = 3 >= 3 → \x1b[K fires before the char.
1254        let out = render_and_capture(
1255            b"B\xe3\x81\x82", // HIRAGANA A (U+3042, width 2 in unicode-width)
1256            1,
1257            3,
1258            false,
1259        );
1260        insta::assert_snapshot!("wide_char_margin", escape_ansi(&out));
1261    }
1262
1263    #[test]
1264    fn snapshot_wide_char_middle() {
1265        // Wide char at col 1 in a 5-wide grid (cols 1-2).  Does NOT
1266        // trigger margin sanitation (1 + 2 = 3 < 5) — verifies wide
1267        // chars render correctly in the middle of a row.
1268        let out = render_and_capture(
1269            b"A\xe3\x81\x82\xe3\x81\x83", // CJK chars at col 1 and col 3
1270            1,
1271            5,
1272            false,
1273        );
1274        insta::assert_snapshot!("wide_char_middle", escape_ansi(&out));
1275    }
1276}