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, MouseButton, MouseEventKind};
26use term_wm_pty_engine::Pane;
27use term_wm_pty_engine::clipboard::{Clipboard, Osc52Extractor};
28use term_wm_pty_engine::input_encoding::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            let _ = 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_pty_bytes(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 pty_mouse = term_wm_pty_engine::input_encoding::MouseEvent {
546                            kind: match mouse.kind {
547                                MouseEventKind::Press(btn) => term_wm_pty_engine::input_encoding::MouseEventKind::Press(match btn {
548                                    MouseButton::Left => term_wm_pty_engine::input_encoding::MouseButton::Left,
549                                    MouseButton::Right => term_wm_pty_engine::input_encoding::MouseButton::Right,
550                                    MouseButton::Middle => term_wm_pty_engine::input_encoding::MouseButton::Middle,
551                                }),
552                                MouseEventKind::Release(btn) => term_wm_pty_engine::input_encoding::MouseEventKind::Release(match btn {
553                                    MouseButton::Left => term_wm_pty_engine::input_encoding::MouseButton::Left,
554                                    MouseButton::Right => term_wm_pty_engine::input_encoding::MouseButton::Right,
555                                    MouseButton::Middle => term_wm_pty_engine::input_encoding::MouseButton::Middle,
556                                }),
557                                MouseEventKind::Drag(btn) => term_wm_pty_engine::input_encoding::MouseEventKind::Drag(match btn {
558                                    MouseButton::Left => term_wm_pty_engine::input_encoding::MouseButton::Left,
559                                    MouseButton::Right => term_wm_pty_engine::input_encoding::MouseButton::Right,
560                                    MouseButton::Middle => term_wm_pty_engine::input_encoding::MouseButton::Middle,
561                                }),
562                                MouseEventKind::Moved => term_wm_pty_engine::input_encoding::MouseEventKind::Moved,
563                                MouseEventKind::ScrollUp => term_wm_pty_engine::input_encoding::MouseEventKind::ScrollUp,
564                                MouseEventKind::ScrollDown => term_wm_pty_engine::input_encoding::MouseEventKind::ScrollDown,
565                                MouseEventKind::ScrollLeft => term_wm_pty_engine::input_encoding::MouseEventKind::ScrollLeft,
566                                MouseEventKind::ScrollRight => term_wm_pty_engine::input_encoding::MouseEventKind::ScrollRight,
567                            },
568                            modifiers: term_wm_pty_engine::input_encoding::KeyModifiers {
569                                shift: mouse.modifiers.shift,
570                                control: mouse.modifiers.control,
571                                alt: mouse.modifiers.alt,
572                            },
573                            column: mouse.column,
574                            row: mouse.row,
575                        };
576                        let bytes = mouse_event_to_bytes(&pty_mouse, MouseProtocolEncoding::Sgr);
577                        if !bytes.is_empty() {
578                            let _ = pane.write_bytes(&bytes);
579                        }
580                    }
581                }
582                Event::Resize(w, h) => {
583                    let size = PtySize {
584                        rows: h,
585                        cols: w,
586                        pixel_width: 0,
587                        pixel_height: 0,
588                    };
589                    if let Err(err) = pane.resize(size) {
590                        tracing::warn!(error = %err, "resize request failed on PTY pane");
591                    }
592                    force_render = true;
593                    clear_display = true;
594                }
595                Event::Paste(text) => {
596                    let mut wrapped = Vec::with_capacity(text.len() + BRACKETED_PASTE_OVERHEAD);
597                    wrapped.extend_from_slice(b"\x1b[200~");
598                    wrapped.extend_from_slice(text.as_bytes());
599                    wrapped.extend_from_slice(b"\x1b[201~");
600                    let _ = pane.write_bytes(&wrapped);
601                }
602                _ => {}
603            }
604        }
605
606        // Connection health — check after wakeup
607        if !client.is_connected() {
608            return Err(io::Error::other("connection to session server lost"));
609        }
610
611        // Full-frame explicit row-by-row render
612        if has_new_data || force_render {
613            let parser = pane.shared_parser();
614            let parser = parser.lock().unwrap();
615            let screen = parser.screen();
616            let (rows, cols) = screen.size();
617            render_frame(&mut out, screen, rows, cols, clear_display)?;
618        }
619
620        // Exit on session exit
621        if pane.has_exited() {
622            return Ok(());
623        }
624    }
625}
626
627#[derive(Default, PartialEq, Clone, Copy)]
628struct CellStyle {
629    fg: vt100::Color,
630    bg: vt100::Color,
631    bold: bool,
632    dim: bool,
633    italic: bool,
634    underline: bool,
635    inverse: bool,
636}
637
638impl CellStyle {
639    fn from_cell(cell: &vt100::Cell) -> Self {
640        Self {
641            fg: cell.fgcolor(),
642            bg: cell.bgcolor(),
643            bold: cell.bold(),
644            dim: cell.dim(),
645            italic: cell.italic(),
646            underline: cell.underline(),
647            inverse: cell.inverse(),
648        }
649    }
650}
651
652fn apply_sgr(out: &mut dyn Write, style: &CellStyle) -> io::Result<()> {
653    write!(out, "\x1b[0m")?;
654    if style.bold {
655        write!(out, "\x1b[1m")?;
656    }
657    if style.dim {
658        write!(out, "\x1b[2m")?;
659    }
660    if style.italic {
661        write!(out, "\x1b[3m")?;
662    }
663    if style.underline {
664        write!(out, "\x1b[4m")?;
665    }
666    if style.inverse {
667        write!(out, "\x1b[7m")?;
668    }
669    match style.fg {
670        vt100::Color::Idx(i) => write!(out, "\x1b[38;5;{}m", i)?,
671        vt100::Color::Rgb(r, g, b) => write!(out, "\x1b[38;2;{};{};{}m", r, g, b)?,
672        _ => {}
673    }
674    match style.bg {
675        vt100::Color::Idx(i) => write!(out, "\x1b[48;5;{}m", i)?,
676        vt100::Color::Rgb(r, g, b) => write!(out, "\x1b[48;2;{};{};{}m", r, g, b)?,
677        _ => {}
678    }
679    Ok(())
680}
681
682pub fn render_frame(
683    out: &mut dyn Write,
684    screen: &Screen,
685    rows: u16,
686    cols: u16,
687    clear_display: bool,
688) -> io::Result<()> {
689    let mut buf =
690        Vec::with_capacity((rows as usize) * (cols as usize) * RENDER_BUF_CELL_MULTIPLIER);
691    let mut active_style = CellStyle::default();
692
693    // Synchronized Output begin, hide cursor, reset attributes
694    buf.extend_from_slice(b"\x1b[?2026h\x1b[?25l\x1b[0m");
695    if clear_display {
696        buf.extend_from_slice(b"\x1b[2J");
697    }
698    buf.extend_from_slice(b"\x1b[?7l");
699
700    for row in 0..rows {
701        write!(buf, "\x1b[{};1H", row + 1)?;
702
703        let mut col: u16 = 0;
704        while col < cols {
705            // Compute cell width first to handle wide chars (CJK, emoji) that
706            // span multiple columns — checking col + width >= cols catches the
707            // right-edge case even when a wide char at cols-2 jumps past cols-1.
708            let cell_opt = screen.cell(row, col);
709            let contents = cell_opt.map_or("", |c| c.contents());
710            let width = if contents.is_empty() {
711                1
712            } else {
713                unicode_width::UnicodeWidthStr::width(contents).max(1) as u16
714            };
715
716            // Margin sanitation: clear right margin before writing the cell that
717            // touches or passes the right edge.  Placing \x1b[K here (while the
718            // cursor is still at col) avoids cursor-inclusive erasure of the cell.
719            if col + width >= cols {
720                buf.extend_from_slice(b"\x1b[0m\x1b[K");
721                active_style = CellStyle::default();
722            }
723
724            let style = cell_opt.map(CellStyle::from_cell).unwrap_or_default();
725            if style != active_style {
726                apply_sgr(&mut buf, &style)?;
727                active_style = style;
728            }
729
730            if contents.is_empty() {
731                buf.push(b' ');
732            } else {
733                buf.extend_from_slice(contents.as_bytes());
734            }
735
736            col += width;
737        }
738    }
739
740    buf.extend_from_slice(b"\x1b[?7h");
741    buf.extend_from_slice(b"\x1b[0m");
742    let (cur_row, cur_col) = screen.cursor_position();
743    write!(buf, "\x1b[{};{}H", cur_row + 1, cur_col + 1)?;
744    if screen.hide_cursor() {
745        buf.extend_from_slice(b"\x1b[?25l");
746    } else {
747        buf.extend_from_slice(b"\x1b[?25h");
748    }
749    // Synchronized Output end — terminal now paints atomically
750    buf.extend_from_slice(b"\x1b[?2026l");
751
752    out.write_all(&buf)?;
753    out.flush()
754}
755
756#[cfg(test)]
757mod tests {
758    use super::*;
759    use std::sync::{Arc, Mutex};
760
761    use term_wm_core::events::{KeyCode, KeyEvent, MouseButton, MouseEvent};
762
763    struct TestWriter {
764        buf: Arc<Mutex<Vec<u8>>>,
765    }
766
767    impl TestWriter {
768        fn new() -> (Self, Arc<Mutex<Vec<u8>>>) {
769            let buf = Arc::new(Mutex::new(Vec::new()));
770            (Self { buf: buf.clone() }, buf)
771        }
772    }
773
774    impl Write for TestWriter {
775        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
776            self.buf.lock().unwrap().extend_from_slice(buf);
777            Ok(buf.len())
778        }
779        fn flush(&mut self) -> io::Result<()> {
780            Ok(())
781        }
782    }
783
784    /// Calls the real `init_terminal()` with a test writer and verifies
785    /// the bracketed paste enable sequence `\x1b[?2004h` is written.
786    /// Under `cargo test` stdin is a pipe, so `is_terminal()` returns false
787    /// and the raw-mode OS call is skipped — only the ANSI output matters.
788    #[test]
789    fn init_terminal_writes_bracketed_paste_enable() {
790        let (writer, buf) = TestWriter::new();
791        let _guard = init_terminal(writer).expect("init_terminal");
792        let bytes = buf.lock().unwrap();
793        assert!(
794            bytes
795                .windows(b"\x1b[?2004h".len())
796                .any(|w| w == b"\x1b[?2004h")
797        );
798    }
799
800    /// Constructs a TerminalGuard with a test writer and verifies that
801    /// dropping it writes the bracketed paste disable sequence `\x1b[?2004l`.
802    #[test]
803    fn terminal_guard_teardown_writes_bracketed_paste_disable() {
804        let (writer, buf) = TestWriter::new();
805        {
806            let _guard = TerminalGuard {
807                writer: Some(writer),
808            };
809        }
810        let bytes = buf.lock().unwrap();
811        assert!(
812            bytes
813                .windows(b"\x1b[?2004l".len())
814                .any(|w| w == b"\x1b[?2004l")
815        );
816    }
817
818    /// Full lifecycle: init_terminal followed by TerminalGuard teardown
819    /// writes both the enable and disable sequences.
820    #[test]
821    fn init_and_teardown_roundtrip_contains_both_sequences() {
822        let (writer, buf) = TestWriter::new();
823        let guard = init_terminal(writer).expect("init_terminal");
824        drop(guard);
825        let bytes = buf.lock().unwrap();
826        assert!(
827            bytes
828                .windows(b"\x1b[?2004h".len())
829                .any(|w| w == b"\x1b[?2004h")
830        );
831        assert!(
832            bytes
833                .windows(b"\x1b[?2004l".len())
834                .any(|w| w == b"\x1b[?2004l")
835        );
836    }
837
838    /// Proves that reusing a parser via set_size + RIS + process yields
839    /// identical screen state to a freshly allocated parser.
840    #[test]
841    fn test_prev_parser_resize_sync_matches_fresh_parser() {
842        let mut prev_parser = vt100::Parser::new(24, 80, 0);
843        prev_parser.process(b"initial screen content");
844
845        // Simulate terminal window resize to 40x120
846        let (new_rows, new_cols) = (40, 120);
847        let new_formatted_content = {
848            let mut p = vt100::Parser::new(new_rows, new_cols, 0);
849            p.process(b"resized screen content");
850            p.screen().contents_formatted().to_vec()
851        };
852
853        // Re-use prev_parser using dimension sync + RIS reset
854        prev_parser.screen_mut().set_size(new_rows, new_cols);
855        prev_parser.process(b"\x1bc");
856        prev_parser.process(&new_formatted_content);
857
858        // Verify against a freshly created parser
859        let mut fresh_parser = vt100::Parser::new(new_rows, new_cols, 0);
860        fresh_parser.process(&new_formatted_content);
861
862        assert_eq!(
863            prev_parser.screen().contents_formatted(),
864            fresh_parser.screen().contents_formatted(),
865            "Reused parser state after set_size + RIS must match fresh parser"
866        );
867    }
868
869    #[test]
870    fn render_frame_outputs_correct_cup_and_sgr() {
871        let mut parser = vt100::Parser::new(4, 8, 0);
872        parser.process(b"\x1b[31mhello\x1b[0m");
873        let screen = parser.screen();
874        let mut buf: Vec<u8> = Vec::new();
875        let (rows, cols) = screen.size();
876        render_frame(&mut buf, screen, rows, cols, false).unwrap();
877        let output = String::from_utf8_lossy(&buf);
878        // Should contain CUP to each row (4 rows)
879        assert!(output.contains("\x1b[1;1H"));
880        assert!(output.contains("\x1b[2;1H"));
881        assert!(output.contains("\x1b[3;1H"));
882        assert!(output.contains("\x1b[4;1H"));
883        // Should contain "hello"
884        assert!(output.contains("hello"));
885        // Should contain red foreground SGR
886        assert!(
887            output.contains("\x1b[38;5;1m") || output.contains("\x1b[31m"),
888            "Expected red foreground SGR in output: {output:?}"
889        );
890        // Should not contain raw ESC characters without following sequences
891        assert!(!output.contains("\x1b\x1b"), "no double ESC sequences");
892    }
893
894    // ── is_coalescable_mouse tests ────────────────────────────────────────
895
896    #[test]
897    fn coalesce_moved_with_moved() {
898        assert!(is_coalescable_mouse(
899            &MouseEventKind::Moved,
900            &KeyModifiers::NONE,
901            &MouseEventKind::Moved,
902            &KeyModifiers::NONE,
903        ));
904    }
905
906    #[test]
907    fn coalesce_drag_same_button() {
908        assert!(is_coalescable_mouse(
909            &MouseEventKind::Drag(MouseButton::Left),
910            &KeyModifiers::NONE,
911            &MouseEventKind::Drag(MouseButton::Left),
912            &KeyModifiers::NONE,
913        ));
914        assert!(is_coalescable_mouse(
915            &MouseEventKind::Drag(MouseButton::Right),
916            &KeyModifiers {
917                shift: true,
918                ..KeyModifiers::NONE
919            },
920            &MouseEventKind::Drag(MouseButton::Right),
921            &KeyModifiers {
922                shift: true,
923                ..KeyModifiers::NONE
924            },
925        ));
926    }
927
928    #[test]
929    fn reject_drag_different_button() {
930        assert!(!is_coalescable_mouse(
931            &MouseEventKind::Drag(MouseButton::Left),
932            &KeyModifiers::NONE,
933            &MouseEventKind::Drag(MouseButton::Right),
934            &KeyModifiers::NONE,
935        ));
936    }
937
938    #[test]
939    fn reject_moved_vs_drag() {
940        assert!(!is_coalescable_mouse(
941            &MouseEventKind::Moved,
942            &KeyModifiers::NONE,
943            &MouseEventKind::Drag(MouseButton::Left),
944            &KeyModifiers::NONE,
945        ));
946    }
947
948    #[test]
949    fn reject_different_modifiers() {
950        assert!(!is_coalescable_mouse(
951            &MouseEventKind::Moved,
952            &KeyModifiers::NONE,
953            &MouseEventKind::Moved,
954            &KeyModifiers {
955                shift: true,
956                ..KeyModifiers::NONE
957            },
958        ));
959        assert!(!is_coalescable_mouse(
960            &MouseEventKind::Drag(MouseButton::Left),
961            &KeyModifiers {
962                control: true,
963                ..KeyModifiers::NONE
964            },
965            &MouseEventKind::Drag(MouseButton::Left),
966            &KeyModifiers::NONE,
967        ));
968    }
969
970    #[test]
971    fn reject_discrete_events() {
972        assert!(!is_coalescable_mouse(
973            &MouseEventKind::Press(MouseButton::Left),
974            &KeyModifiers::NONE,
975            &MouseEventKind::Press(MouseButton::Left),
976            &KeyModifiers::NONE,
977        ));
978        assert!(!is_coalescable_mouse(
979            &MouseEventKind::Release(MouseButton::Left),
980            &KeyModifiers::NONE,
981            &MouseEventKind::Moved,
982            &KeyModifiers::NONE,
983        ));
984        assert!(!is_coalescable_mouse(
985            &MouseEventKind::Moved,
986            &KeyModifiers::NONE,
987            &MouseEventKind::ScrollDown,
988            &KeyModifiers::NONE,
989        ));
990        assert!(!is_coalescable_mouse(
991            &MouseEventKind::ScrollUp,
992            &KeyModifiers::NONE,
993            &MouseEventKind::ScrollUp,
994            &KeyModifiers::NONE,
995        ));
996    }
997
998    // ── Coalescing loop integration tests ─────────────────────────────────
999
1000    /// Helper: run the coalescing logic from the main loop against a real
1001    /// bounded channel, returning the final event (or None if filtered away).
1002    fn coalesce_through(
1003        events: &[Event],
1004        kind: MouseEventKind,
1005        modifiers: KeyModifiers,
1006    ) -> Option<Event> {
1007        let (tx, rx) = crossbeam_channel::bounded::<Event>(events.len());
1008        for e in events.iter().cloned() {
1009            tx.send(e).ok();
1010        }
1011        drop(tx);
1012
1013        let mut result = Event::Mouse(MouseEvent {
1014            kind,
1015            modifiers,
1016            column: 0,
1017            row: 0,
1018        });
1019
1020        if let Event::Mouse(ref mut mouse) = result
1021            && matches!(mouse.kind, MouseEventKind::Moved | MouseEventKind::Drag(_))
1022        {
1023            while let Ok(next) = rx.try_recv() {
1024                match next {
1025                    Event::Mouse(ref next_mouse)
1026                        if is_coalescable_mouse(
1027                            &mouse.kind,
1028                            &mouse.modifiers,
1029                            &next_mouse.kind,
1030                            &next_mouse.modifiers,
1031                        ) =>
1032                    {
1033                        *mouse = *next_mouse;
1034                    }
1035                    _other => return Some(result),
1036                }
1037            }
1038        }
1039
1040        Some(result)
1041    }
1042
1043    #[test]
1044    fn coalesce_keeps_latest_moved_position() {
1045        let events = vec![
1046            Event::Mouse(MouseEvent {
1047                kind: MouseEventKind::Moved,
1048                modifiers: KeyModifiers::NONE,
1049                column: 5,
1050                row: 5,
1051            }),
1052            Event::Mouse(MouseEvent {
1053                kind: MouseEventKind::Moved,
1054                modifiers: KeyModifiers::NONE,
1055                column: 10,
1056                row: 10,
1057            }),
1058            Event::Mouse(MouseEvent {
1059                kind: MouseEventKind::Moved,
1060                modifiers: KeyModifiers::NONE,
1061                column: 15,
1062                row: 15,
1063            }),
1064        ];
1065        let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1066        let Event::Mouse(m) = result.unwrap() else {
1067            panic!("expected mouse")
1068        };
1069        assert_eq!((m.column, m.row), (15, 15));
1070    }
1071
1072    #[test]
1073    fn coalesce_keeps_latest_drag_position() {
1074        let events = vec![
1075            Event::Mouse(MouseEvent {
1076                kind: MouseEventKind::Drag(MouseButton::Left),
1077                modifiers: KeyModifiers::NONE,
1078                column: 1,
1079                row: 1,
1080            }),
1081            Event::Mouse(MouseEvent {
1082                kind: MouseEventKind::Drag(MouseButton::Left),
1083                modifiers: KeyModifiers::NONE,
1084                column: 2,
1085                row: 2,
1086            }),
1087        ];
1088        let result = coalesce_through(
1089            &events,
1090            MouseEventKind::Drag(MouseButton::Left),
1091            KeyModifiers::NONE,
1092        );
1093        let Event::Mouse(m) = result.unwrap() else {
1094            panic!("expected mouse")
1095        };
1096        assert_eq!((m.column, m.row), (2, 2));
1097    }
1098
1099    #[test]
1100    fn coalesce_stops_at_modifier_change() {
1101        let events = vec![Event::Mouse(MouseEvent {
1102            kind: MouseEventKind::Moved,
1103            modifiers: KeyModifiers {
1104                shift: true,
1105                ..KeyModifiers::NONE
1106            },
1107            column: 99,
1108            row: 99,
1109        })];
1110        let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1111        let Event::Mouse(m) = result.unwrap() else {
1112            panic!("expected mouse")
1113        };
1114        // The first event (modifier change) should NOT be consumed — we
1115        // still hold the original event at (0,0) with NONE modifiers.
1116        assert_eq!((m.column, m.row), (0, 0));
1117    }
1118
1119    #[test]
1120    fn coalesce_stops_at_non_mouse_event() {
1121        let key = Event::Key(KeyEvent {
1122            code: KeyCode::Char('q'),
1123            kind: KeyKind::Press,
1124            modifiers: KeyModifiers::NONE,
1125        });
1126        let events = vec![key.clone()];
1127        let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1128        let Event::Mouse(m) = result.unwrap() else {
1129            panic!("expected mouse")
1130        };
1131        // Should retain original event, not consuming the key
1132        assert_eq!((m.column, m.row), (0, 0));
1133    }
1134
1135    #[test]
1136    fn coalesce_stops_at_discrete_mouse_event() {
1137        let events = vec![Event::Mouse(MouseEvent {
1138            kind: MouseEventKind::Press(MouseButton::Left),
1139            modifiers: KeyModifiers::NONE,
1140            column: 10,
1141            row: 10,
1142        })];
1143        let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1144        let Event::Mouse(m) = result.unwrap() else {
1145            panic!("expected mouse")
1146        };
1147        // Should NOT consume the Press event
1148        assert_eq!((m.column, m.row), (0, 0));
1149    }
1150}
1151
1152// ── Snapshot tests for render_frame byte output ─────────────────────────
1153// Uses a push_rx mock (crossbeam channel) + RemotePane(client: None) for
1154// deterministic, non-flaky byte-stream assertions.
1155#[cfg(test)]
1156#[allow(clippy::type_complexity)]
1157mod snapshot_tests {
1158    use super::*;
1159
1160    /// Render a screen from deterministic PTY bytes, capturing the raw
1161    /// ANSI output.
1162    fn render_and_capture(pty_bytes: &[u8], rows: u16, cols: u16, clear_display: bool) -> Vec<u8> {
1163        let rt = tokio::runtime::Builder::new_current_thread()
1164            .build()
1165            .expect("tokio rt");
1166        let (push_tx, push_rx) = crossbeam_channel::bounded(16);
1167        let input_writer: Box<dyn FnMut(&[u8]) -> io::Result<()> + Send> = Box::new(|_| Ok(()));
1168        let mut pane = RemotePane::new(
1169            0,
1170            None,
1171            rt.handle().clone(),
1172            cols,
1173            rows,
1174            push_rx,
1175            input_writer,
1176        );
1177        drop(rt); // rt must outlive the channels but not RemotePane
1178
1179        push_tx.send(pty_bytes.to_vec()).ok();
1180        pane.drain_pushes();
1181
1182        let parser = pane.shared_parser();
1183        let parser = parser.lock().unwrap();
1184        let screen = parser.screen();
1185        let (rows, cols) = screen.size();
1186        let mut out = Vec::new();
1187        render_frame(&mut out, screen, rows, cols, clear_display).unwrap();
1188        out
1189    }
1190
1191    /// Escape ANSI and control bytes for readable snapshot diffs.
1192    fn escape_ansi(bytes: &[u8]) -> String {
1193        let mut out: Vec<u8> = Vec::with_capacity(bytes.len() * 4);
1194        for &b in bytes {
1195            match b {
1196                b'\x1b' => out.extend_from_slice(b"\\x1b"),
1197                b'\n' => out.extend_from_slice(b"\\n"),
1198                b'\r' => out.extend_from_slice(b"\\r"),
1199                b'\t' => out.extend_from_slice(b"\\t"),
1200                0x20..=0x7e => out.push(b),
1201                _ => {
1202                    out.push(b'\\');
1203                    out.push(b'x');
1204                    out.extend_from_slice(&hex_byte(b));
1205                }
1206            }
1207        }
1208        // SAFETY: all bytes are valid ASCII (0x20-0x7e or escaped sequences)
1209        unsafe { String::from_utf8_unchecked(out) }
1210    }
1211
1212    fn hex_byte(b: u8) -> [u8; 2] {
1213        #[inline]
1214        fn hex_nibble(n: u8) -> u8 {
1215            let digit = n & 0x0f;
1216            if digit < 10 {
1217                b'0' + digit
1218            } else {
1219                b'a' + digit - 10
1220            }
1221        }
1222        [hex_nibble(b >> 4), hex_nibble(b)]
1223    }
1224
1225    // ── Tests ────────────────────────────────────────────────────────
1226
1227    #[test]
1228    fn snapshot_empty_grid() {
1229        let out = render_and_capture(b"", 4, 8, false);
1230        insta::assert_snapshot!("empty_grid", escape_ansi(&out));
1231    }
1232
1233    #[test]
1234    fn snapshot_basic_text() {
1235        let out = render_and_capture(b"Hello\nWorld", 4, 8, false);
1236        insta::assert_snapshot!("basic_text", escape_ansi(&out));
1237    }
1238
1239    #[test]
1240    fn snapshot_colored_text() {
1241        let out = render_and_capture(b"\x1b[31mred\x1b[1mbold", 4, 8, false);
1242        insta::assert_snapshot!("colored_text", escape_ansi(&out));
1243    }
1244
1245    #[test]
1246    fn snapshot_normal_char_at_margin() {
1247        // Fill a 4-wide grid so the last column contains 'D' — triggers
1248        // margin sanitation before the final cell in the row.
1249        let out = render_and_capture(b"ABCD", 1, 4, false);
1250        insta::assert_snapshot!("normal_char_at_margin", escape_ansi(&out));
1251    }
1252
1253    #[test]
1254    fn snapshot_clear_display() {
1255        let out = render_and_capture(b"", 4, 8, true);
1256        insta::assert_snapshot!("clear_display", escape_ansi(&out));
1257    }
1258
1259    #[test]
1260    fn snapshot_hidden_cursor() {
1261        let out = render_and_capture(b"\x1b[?25l", 4, 8, false);
1262        insta::assert_snapshot!("hidden_cursor", escape_ansi(&out));
1263    }
1264
1265    #[test]
1266    fn snapshot_color_across_margin() {
1267        // Red background on a block char right at the last column.
1268        // Verify \x1b[0m resets the color before \x1b[K clears the margin.
1269        let out = render_and_capture(b"\x1b[41mX", 1, 4, false);
1270        insta::assert_snapshot!("color_across_margin", escape_ansi(&out));
1271    }
1272
1273    #[test]
1274    fn snapshot_multi_row_fill() {
1275        // Fill all 4×3 cells with unique chars to verify row-by-row CUP +
1276        // margin sanitation on every row.
1277        let out = render_and_capture(b"ABCDEFGHIJKL", 3, 4, false);
1278        insta::assert_snapshot!("multi_row_fill", escape_ansi(&out));
1279    }
1280
1281    #[test]
1282    fn snapshot_wide_char_margin() {
1283        // Use 3-wide grid with CJK at col 1 (width 2, fills cols 1-2).
1284        // Margin check: 1 + 2 = 3 >= 3 → \x1b[K fires before the char.
1285        let out = render_and_capture(
1286            b"B\xe3\x81\x82", // HIRAGANA A (U+3042, width 2 in unicode-width)
1287            1,
1288            3,
1289            false,
1290        );
1291        insta::assert_snapshot!("wide_char_margin", escape_ansi(&out));
1292    }
1293
1294    #[test]
1295    fn snapshot_wide_char_middle() {
1296        // Wide char at col 1 in a 5-wide grid (cols 1-2).  Does NOT
1297        // trigger margin sanitation (1 + 2 = 3 < 5) — verifies wide
1298        // chars render correctly in the middle of a row.
1299        let out = render_and_capture(
1300            b"A\xe3\x81\x82\xe3\x81\x83", // CJK chars at col 1 and col 3
1301            1,
1302            5,
1303            false,
1304        );
1305        insta::assert_snapshot!("wide_char_middle", escape_ansi(&out));
1306    }
1307}