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 Attach, OnPtyResized, RpcMethodPrebuffered, STREAM_INPUT_METHOD_ID, SUBSCRIBE_OUTPUT_METHOD_ID,
24 Spawn,
25};
26use term_wm_events::{Event, KeyKind, KeyModifiers, MouseEventKind};
27use term_wm_pty_engine::Pane;
28use term_wm_pty_engine::clipboard::{Clipboard, Osc52Extractor};
29use term_wm_pty_engine::input_encoding::{key_to_bytes, mouse_event_to_bytes};
30use term_wm_pty_engine::signal::install_sigint_handler;
31use vt100::{MouseProtocolEncoding, MouseProtocolMode, Parser, Screen};
32
33#[cfg(unix)]
41pub fn redirect_fd_to_tracing(target_fd: libc::c_int, is_stderr: bool) -> std::io::Result<()> {
42 let mut fds: [libc::c_int; 2] = [0; 2];
43 unsafe {
44 if libc::pipe(fds.as_mut_ptr()) == -1 {
45 return Err(std::io::Error::last_os_error());
46 }
47 if libc::dup2(fds[1], target_fd) == -1 {
48 libc::close(fds[0]);
49 libc::close(fds[1]);
50 return Err(std::io::Error::last_os_error());
51 }
52 libc::close(fds[1]);
53 }
54 let read_fd = fds[0];
55 let name = if is_stderr {
56 "stderr-tracing"
57 } else {
58 "stdout-tracing"
59 };
60 std::thread::Builder::new()
61 .name(name.into())
62 .spawn(move || {
63 use std::io::BufRead;
64 let file = unsafe { std::fs::File::from_raw_fd(read_fd) };
65 let mut reader = std::io::BufReader::new(file);
66 let mut buf = Vec::new();
67 while reader.read_until(b'\n', &mut buf).unwrap_or(0) > 0 {
68 let text = String::from_utf8_lossy(&buf);
69 let trimmed = text.trim();
70 if !trimmed.is_empty() {
71 if is_stderr {
72 tracing::error!(target: "c_stderr", "{}", trimmed);
73 } else {
74 tracing::info!(target: "c_stdout", "{}", trimmed);
75 }
76 }
77 buf.clear();
78 }
79 })?;
80 Ok(())
81}
82
83#[cfg(windows)]
87fn disable_quick_edit() {
88 use windows_sys::Win32::System::Console::{
89 ENABLE_EXTENDED_FLAGS, ENABLE_QUICK_EDIT_MODE, GetConsoleMode, GetStdHandle,
90 STD_INPUT_HANDLE, SetConsoleMode,
91 };
92
93 unsafe {
94 let handle = GetStdHandle(STD_INPUT_HANDLE);
95 let mut mode: u32 = 0;
96 if GetConsoleMode(handle, &mut mode) != 0 {
97 let _ = SetConsoleMode(
98 handle,
99 (mode & !ENABLE_QUICK_EDIT_MODE) | ENABLE_EXTENDED_FLAGS,
100 );
101 }
102 }
103}
104
105#[cfg(target_os = "windows")]
108const INITIAL_WAIT_ITERS: usize = 60;
109#[cfg(not(target_os = "windows"))]
110const INITIAL_WAIT_ITERS: usize = 20;
111
112const PTY_OUTPUT_CHANNEL_CAPACITY: usize = 256;
114const CLIPBOARD_CHANNEL_CAPACITY: usize = 64;
116const INPUT_CHANNEL_CAPACITY: usize = 64;
118
119const PREV_TAIL_LEN: usize = 8;
123
124const INITIAL_WAIT_SLEEP_MS: u64 = 50;
126
127const INPUT_POLL_MS: u64 = 50;
130
131const BACKPRESSURE_SLEEP_MS: u64 = 1;
134
135const BRACKETED_PASTE_OVERHEAD: usize = 12;
138
139const RENDER_BUF_CELL_MULTIPLIER: usize = 3;
141
142const MIN_TERM_COLS: u16 = 2;
146const MIN_TERM_ROWS: u16 = 2;
147
148pub fn init_terminal<W: Write>(mut writer: W) -> io::Result<TerminalGuard<W>> {
156 if std::io::stdin().is_terminal() {
157 enable_raw_mode()?;
158 }
159 writer.queue(EnterAlternateScreen)?;
160 writer.queue(Hide)?;
161 writer.queue(EnableBracketedPaste)?;
162 writer.queue(crossterm::event::EnableMouseCapture)?;
163 writer.flush()?;
164 Ok(TerminalGuard {
165 writer: Some(writer),
166 })
167}
168
169pub struct TerminalGuard<W: Write = std::io::Stdout> {
173 writer: Option<W>,
174}
175
176impl<W: Write> Drop for TerminalGuard<W> {
177 fn drop(&mut self) {
178 if let Some(ref mut writer) = self.writer {
179 let _ = writer.queue(crossterm::event::DisableMouseCapture);
180 let _ = writer.queue(DisableBracketedPaste);
181 let _ = writer.queue(Show);
182 let _ = writer.queue(LeaveAlternateScreen);
183 if std::io::stdin().is_terminal() {
184 let _ = disable_raw_mode();
185 }
186 let _ = writer.flush();
187 }
188 }
189}
190
191fn convert_crossterm_event(evt: crossterm::event::Event) -> Option<Event> {
193 term_wm_crossterm_adapter::try_translate_event(evt)
194}
195
196fn is_coalescable_mouse(
201 a_kind: &MouseEventKind,
202 a_mod: &KeyModifiers,
203 b_kind: &MouseEventKind,
204 b_mod: &KeyModifiers,
205) -> bool {
206 if a_mod != b_mod {
207 return false;
208 }
209 match (a_kind, b_kind) {
210 (MouseEventKind::Moved, MouseEventKind::Moved) => true,
211 (MouseEventKind::Drag(btn1), MouseEventKind::Drag(btn2)) => btn1 == btn2,
212 _ => false,
213 }
214}
215
216pub fn run_session(socket_path: &str, channel: &str, cmd: &[String]) -> io::Result<()> {
227 #[cfg(windows)]
232 disable_quick_edit();
233
234 #[cfg(unix)]
238 let _ = redirect_fd_to_tracing(libc::STDERR_FILENO, true);
239
240 let rt =
241 tokio::runtime::Runtime::new().map_err(|e| io::Error::other(format!("runtime: {e}")))?;
242
243 let client: Arc<RpcIpcClient> = rt
245 .block_on(RpcIpcClient::new(socket_path))
246 .map_err(|e| io::Error::new(io::ErrorKind::ConnectionRefused, format!("{e:?}")))?;
247
248 let abi_fault = |e: &dyn std::fmt::Display| -> io::Error {
252 io::Error::other(format!(
253 "FATAL: Protocol ABI mismatch. A legacy daemon may be occupying the IPC socket. Manually terminate the daemon process before continuing. (cause: {e})"
254 ))
255 };
256
257 let server_cols = Arc::new(AtomicU16::new(0));
261 let server_rows = Arc::new(AtomicU16::new(0));
262 let resize_pending = Arc::new(AtomicBool::new(false));
263
264 {
265 let cols_ref = Arc::clone(&server_cols);
266 let rows_ref = Arc::clone(&server_rows);
267 let pending_ref = Arc::clone(&resize_pending);
268 rt.block_on(client.get_endpoint().register_prebuffered(
269 OnPtyResized::METHOD_ID,
270 move |payload, _ctx| {
271 let cols_ref = Arc::clone(&cols_ref);
272 let rows_ref = Arc::clone(&rows_ref);
273 let pending_ref = Arc::clone(&pending_ref);
274 async move {
275 let (cols, rows) = OnPtyResized::decode_request(&payload)
276 .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
277 cols_ref.store(cols, Ordering::Relaxed);
278 rows_ref.store(rows, Ordering::Relaxed);
279 pending_ref.store(true, Ordering::Relaxed);
280 OnPtyResized::encode_response(())
281 .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
282 }
283 },
284 ))
285 .map_err(|e| io::Error::other(format!("register OnPtyResized: {e:?}")))?;
286 }
287
288 let (push_tx, push_rx) = crossbeam_channel::bounded::<Vec<u8>>(PTY_OUTPUT_CHANNEL_CAPACITY);
292 let (clip_tx, clip_rx) = crossbeam_channel::bounded::<String>(CLIPBOARD_CHANNEL_CAPACITY);
293
294 let (term_cols, term_rows) = {
298 let (c, r) = crossterm::terminal::size()?;
299 (c.max(MIN_TERM_COLS), r.max(MIN_TERM_ROWS))
300 };
301 let hostname = hostname::get()
302 .map(|h| h.to_string_lossy().into_owned())
303 .unwrap_or_else(|_| "unknown".to_string());
304
305 let (actual_cols, actual_rows) = rt.block_on(async {
306 let conn_id = Attach::call(
309 &*client,
310 (channel.to_string(), hostname, std::process::id() as u64),
311 )
312 .await
313 .map_err(|e| abi_fault(&e))?;
314 let cmd = if cmd.is_empty() {
316 None
317 } else {
318 Some(cmd.to_vec())
319 };
320 let (_session_id, actual_cols, actual_rows) =
321 Spawn::call(&*client, (cmd, term_cols, term_rows))
322 .await
323 .map_err(|e| abi_fault(&e))?;
324 let _ = conn_id;
325 Ok::<(u16, u16), io::Error>((actual_cols, actual_rows))
326 })?;
327
328 let writer = rt.block_on(async {
330 let (_, mut reader) = client
333 .open_channel(SUBSCRIBE_OUTPUT_METHOD_ID, 0)
334 .await
335 .map_err(|e| io::Error::other(format!("subscribe: {e:?}")))?;
336
337 rt.spawn(async move {
341 let mut osc52 = Osc52Extractor::new();
342 let mut prev_tail: [u8; PREV_TAIL_LEN] = [0; PREV_TAIL_LEN];
343
344 while let Some(chunk) = reader.recv().await {
345 if let Ok(mut data) = chunk {
346 if let Some(text) = osc52.push(&data, &prev_tail) {
347 let _ = clip_tx.try_send(text);
348 }
349
350 let n = data.len();
351 if n >= PREV_TAIL_LEN {
352 prev_tail.copy_from_slice(&data[n - PREV_TAIL_LEN..n]);
353 } else if n > 0 {
354 prev_tail.rotate_left(n);
355 prev_tail[PREV_TAIL_LEN - n..].copy_from_slice(&data[..n]);
356 }
357
358 while let Err(crossbeam_channel::TrySendError::Full(pending)) =
361 push_tx.try_send(data)
362 {
363 data = pending;
364 tokio::time::sleep(Duration::from_millis(BACKPRESSURE_SLEEP_MS)).await;
365 }
366 } else {
367 break;
368 }
369 }
370 if let Some(text) = osc52.finish() {
373 let _ = clip_tx.try_send(text);
374 }
375 });
376
377 let (writer, _) = client
380 .open_channel(STREAM_INPUT_METHOD_ID, 0)
381 .await
382 .map_err(|e| io::Error::other(format!("stream input: {e:?}")))?;
383
384 Ok::<_, io::Error>(writer)
385 })?;
386
387 let input_writer = Box::new(move |data: &[u8]| -> io::Result<()> {
388 writer
389 .send(data.to_vec())
390 .map_err(|e| io::Error::other(e.to_string()))?;
391 Ok(())
392 });
393
394 let mut pane = RemotePane::new(
395 1u64,
396 Some(client.clone()),
397 rt.handle().clone(),
398 term_cols,
399 term_rows,
400 push_rx.clone(),
401 input_writer,
402 );
403
404 for _ in 0..INITIAL_WAIT_ITERS {
406 pane.drain_pushes();
407 let parser = pane.shared_parser();
408 let parser = parser.lock().unwrap();
409 if !parser.screen().contents_formatted().is_empty() {
410 break;
411 }
412 drop(parser);
413 std::thread::sleep(Duration::from_millis(INITIAL_WAIT_SLEEP_MS));
414 }
415
416 {
418 let parser = pane.shared_parser();
419 let mut parser_lk = parser.lock().unwrap();
420 let (cur_rows, cur_cols) = parser_lk.screen().size();
421 if actual_cols != cur_cols || actual_rows != cur_rows {
422 parser_lk.screen_mut().set_size(actual_rows, actual_cols);
423 }
424 drop(parser_lk);
425 }
426
427 let _guard = init_terminal(stdout())?;
430 let mut out = stdout();
431
432 let mut clipboard = Clipboard::new();
433 let sigint = install_sigint_handler()?;
434
435 let (input_tx, input_rx) = crossbeam_channel::bounded::<Event>(INPUT_CHANNEL_CAPACITY);
437
438 std::thread::Builder::new()
442 .name("crossterm-input".into())
443 .spawn(move || {
444 loop {
445 match crossterm::event::poll(Duration::from_millis(INPUT_POLL_MS)) {
446 Ok(true) => {
447 if let Ok(crossterm_evt) = crossterm::event::read()
448 && let Some(e) = convert_crossterm_event(crossterm_evt)
449 && input_tx.send(e).is_err()
450 {
451 break;
452 }
453 }
454 Ok(false) => continue,
455 Err(_) => break,
456 }
457 }
458 })
459 .map_err(|e| io::Error::other(format!("spawn input thread: {e}")))?;
460
461 {
463 let parser = pane.shared_parser();
464 let parser = parser.lock().unwrap();
465 let screen = parser.screen();
466 let (rows, cols) = screen.size();
467 render_frame(&mut out, screen, rows, cols, false)?;
468 }
469
470 let mut pending_input: Option<Event> = None;
471 loop {
472 let mut force_render = false;
473 let mut clear_display = false;
474
475 let apply_pending_resize = |shared_parser: &Arc<Mutex<Parser>>| -> bool {
478 if resize_pending.swap(false, Ordering::Relaxed) {
479 let cols = server_cols.load(Ordering::Relaxed);
480 let rows = server_rows.load(Ordering::Relaxed);
481 if cols > 0 && rows > 0 {
482 let mut parser_lk = shared_parser.lock().unwrap();
483 let (cur_rows, cur_cols) = parser_lk.screen().size();
484 if cur_cols != cols || cur_rows != rows {
485 parser_lk.screen_mut().set_size(rows, cols);
486 return true;
487 }
488 }
489 }
490 false
491 };
492
493 let resized = apply_pending_resize(&pane.shared_parser());
495 force_render |= resized;
496 clear_display |= resized;
497
498 let input_event = if let Some(evt) = pending_input.take() {
501 Some(evt)
502 } else {
503 crossbeam_channel::select! {
504 recv(input_rx) -> msg => {
505 match msg {
506 Ok(evt) => Some(evt),
507 Err(_) => return Err(io::Error::other("input thread died")),
508 }
509 }
510 recv(push_rx) -> msg => {
511 match msg {
512 Ok(data) => {
513 let resized = apply_pending_resize(&pane.shared_parser());
518 force_render |= resized;
519 clear_display |= resized;
520
521 let parser = pane.shared_parser();
523 let mut parser = parser.lock().unwrap();
524 parser.process(&data);
525 None
526 }
527 Err(_) => {
528 None
531 }
532 }
533 }
534 }
535 };
536
537 let has_new_data = pane.drain_pushes() || input_event.is_none();
539
540 while let Ok(text) = clip_rx.try_recv() {
542 clipboard.set(&text);
543 }
544
545 if sigint.received() {
547 sigint.ack();
548 let _ = pane.write_bytes(&[0x03]);
549 }
550
551 if let Some(mut evt) = input_event {
553 if let Event::Mouse(ref mut mouse) = evt
558 && matches!(mouse.kind, MouseEventKind::Moved | MouseEventKind::Drag(_))
559 {
560 while let Ok(next_evt) = input_rx.try_recv() {
561 match next_evt {
562 Event::Mouse(ref next_mouse)
563 if is_coalescable_mouse(
564 &mouse.kind,
565 &mouse.modifiers,
566 &next_mouse.kind,
567 &next_mouse.modifiers,
568 ) =>
569 {
570 *mouse = *next_mouse;
571 }
572 other => {
573 pending_input = Some(other);
574 break;
575 }
576 }
577 }
578 }
579
580 match evt {
581 Event::Key(ref key)
582 if key.kind == KeyKind::Press || key.kind == KeyKind::Repeat =>
583 {
584 let bytes = key_to_bytes(key, false);
585 if !bytes.is_empty() {
586 let _ = pane.write_bytes(&bytes);
587 }
588 }
589 Event::Mouse(ref mouse) => {
590 let mouse_active = {
591 let parser = pane.shared_parser();
592 let parser = parser.lock().unwrap();
593 parser.screen().mouse_protocol_mode() != MouseProtocolMode::None
594 };
595 if mouse_active {
596 let bytes = mouse_event_to_bytes(mouse, MouseProtocolEncoding::Sgr);
597 if !bytes.is_empty() {
598 let _ = pane.write_bytes(&bytes);
599 }
600 }
601 }
602 Event::Resize(w, h) => {
603 let size = PtySize {
604 rows: h,
605 cols: w,
606 pixel_width: 0,
607 pixel_height: 0,
608 };
609 if let Err(err) = pane.resize(size) {
610 tracing::warn!(error = %err, "resize request failed on PTY pane");
611 }
612 force_render = true;
613 clear_display = true;
614 }
615 Event::Paste(text) => {
616 let mut wrapped = Vec::with_capacity(text.len() + BRACKETED_PASTE_OVERHEAD);
617 wrapped.extend_from_slice(b"\x1b[200~");
618 wrapped.extend_from_slice(text.as_bytes());
619 wrapped.extend_from_slice(b"\x1b[201~");
620 let _ = pane.write_bytes(&wrapped);
621 }
622 _ => {}
623 }
624 }
625
626 if !client.is_connected() {
628 return Err(io::Error::other("connection to session server lost"));
629 }
630
631 if has_new_data || force_render {
633 let parser = pane.shared_parser();
634 let parser = parser.lock().unwrap();
635 let screen = parser.screen();
636 let (rows, cols) = screen.size();
637 render_frame(&mut out, screen, rows, cols, clear_display)?;
638 }
639
640 if pane.has_exited() {
642 return Ok(());
643 }
644 }
645}
646
647#[derive(Default, PartialEq, Clone, Copy)]
648struct CellStyle {
649 fg: vt100::Color,
650 bg: vt100::Color,
651 bold: bool,
652 dim: bool,
653 italic: bool,
654 underline: bool,
655 inverse: bool,
656}
657
658impl CellStyle {
659 fn from_cell(cell: &vt100::Cell) -> Self {
660 Self {
661 fg: cell.fgcolor(),
662 bg: cell.bgcolor(),
663 bold: cell.bold(),
664 dim: cell.dim(),
665 italic: cell.italic(),
666 underline: cell.underline(),
667 inverse: cell.inverse(),
668 }
669 }
670}
671
672fn apply_sgr(out: &mut dyn Write, style: &CellStyle) -> io::Result<()> {
673 write!(out, "\x1b[0m")?;
674 if style.bold {
675 write!(out, "\x1b[1m")?;
676 }
677 if style.dim {
678 write!(out, "\x1b[2m")?;
679 }
680 if style.italic {
681 write!(out, "\x1b[3m")?;
682 }
683 if style.underline {
684 write!(out, "\x1b[4m")?;
685 }
686 if style.inverse {
687 write!(out, "\x1b[7m")?;
688 }
689 match style.fg {
690 vt100::Color::Idx(i) => write!(out, "\x1b[38;5;{}m", i)?,
691 vt100::Color::Rgb(r, g, b) => write!(out, "\x1b[38;2;{};{};{}m", r, g, b)?,
692 _ => {}
693 }
694 match style.bg {
695 vt100::Color::Idx(i) => write!(out, "\x1b[48;5;{}m", i)?,
696 vt100::Color::Rgb(r, g, b) => write!(out, "\x1b[48;2;{};{};{}m", r, g, b)?,
697 _ => {}
698 }
699 Ok(())
700}
701
702pub fn render_frame(
703 out: &mut dyn Write,
704 screen: &Screen,
705 rows: u16,
706 cols: u16,
707 clear_display: bool,
708) -> io::Result<()> {
709 let mut buf =
710 Vec::with_capacity((rows as usize) * (cols as usize) * RENDER_BUF_CELL_MULTIPLIER);
711 let mut active_style = CellStyle::default();
712
713 buf.extend_from_slice(b"\x1b[?2026h\x1b[?25l\x1b[0m");
715 if clear_display {
716 buf.extend_from_slice(b"\x1b[2J");
717 }
718 buf.extend_from_slice(b"\x1b[?7l");
719
720 for row in 0..rows {
721 write!(buf, "\x1b[{};1H", row + 1)?;
722
723 let mut col: u16 = 0;
724 while col < cols {
725 let cell_opt = screen.cell(row, col);
729 let contents = cell_opt.map_or("", |c| c.contents());
730 let width = if contents.is_empty() {
731 1
732 } else {
733 unicode_width::UnicodeWidthStr::width(contents).max(1) as u16
734 };
735
736 if col + width >= cols {
740 buf.extend_from_slice(b"\x1b[0m\x1b[K");
741 active_style = CellStyle::default();
742 }
743
744 let style = cell_opt.map(CellStyle::from_cell).unwrap_or_default();
745 if style != active_style {
746 apply_sgr(&mut buf, &style)?;
747 active_style = style;
748 }
749
750 if contents.is_empty() {
751 buf.push(b' ');
752 } else {
753 buf.extend_from_slice(contents.as_bytes());
754 }
755
756 col += width;
757 }
758 }
759
760 buf.extend_from_slice(b"\x1b[?7h");
761 buf.extend_from_slice(b"\x1b[0m");
762 let (cur_row, cur_col) = screen.cursor_position();
763 write!(buf, "\x1b[{};{}H", cur_row + 1, cur_col + 1)?;
764 if screen.hide_cursor() {
765 buf.extend_from_slice(b"\x1b[?25l");
766 } else {
767 buf.extend_from_slice(b"\x1b[?25h");
768 }
769 buf.extend_from_slice(b"\x1b[?2026l");
771
772 out.write_all(&buf)?;
773 out.flush()
774}
775
776#[cfg(test)]
777mod tests {
778 use super::*;
779 use std::sync::{Arc, Mutex};
780
781 use term_wm_events::{KeyCode, KeyEvent, MouseButton, MouseEvent};
782
783 struct TestWriter {
784 buf: Arc<Mutex<Vec<u8>>>,
785 }
786
787 impl TestWriter {
788 fn new() -> (Self, Arc<Mutex<Vec<u8>>>) {
789 let buf = Arc::new(Mutex::new(Vec::new()));
790 (Self { buf: buf.clone() }, buf)
791 }
792 }
793
794 impl Write for TestWriter {
795 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
796 self.buf.lock().unwrap().extend_from_slice(buf);
797 Ok(buf.len())
798 }
799 fn flush(&mut self) -> io::Result<()> {
800 Ok(())
801 }
802 }
803
804 #[test]
809 fn init_terminal_writes_bracketed_paste_enable() {
810 let (writer, buf) = TestWriter::new();
811 let _guard = init_terminal(writer).expect("init_terminal");
812 let bytes = buf.lock().unwrap();
813 assert!(
814 bytes
815 .windows(b"\x1b[?2004h".len())
816 .any(|w| w == b"\x1b[?2004h")
817 );
818 }
819
820 #[test]
823 fn terminal_guard_teardown_writes_bracketed_paste_disable() {
824 let (writer, buf) = TestWriter::new();
825 {
826 let _guard = TerminalGuard {
827 writer: Some(writer),
828 };
829 }
830 let bytes = buf.lock().unwrap();
831 assert!(
832 bytes
833 .windows(b"\x1b[?2004l".len())
834 .any(|w| w == b"\x1b[?2004l")
835 );
836 }
837
838 #[test]
841 fn init_and_teardown_roundtrip_contains_both_sequences() {
842 let (writer, buf) = TestWriter::new();
843 let guard = init_terminal(writer).expect("init_terminal");
844 drop(guard);
845 let bytes = buf.lock().unwrap();
846 assert!(
847 bytes
848 .windows(b"\x1b[?2004h".len())
849 .any(|w| w == b"\x1b[?2004h")
850 );
851 assert!(
852 bytes
853 .windows(b"\x1b[?2004l".len())
854 .any(|w| w == b"\x1b[?2004l")
855 );
856 }
857
858 #[test]
861 fn test_prev_parser_resize_sync_matches_fresh_parser() {
862 let mut prev_parser = vt100::Parser::new(24, 80, 0);
863 prev_parser.process(b"initial screen content");
864
865 let (new_rows, new_cols) = (40, 120);
867 let new_formatted_content = {
868 let mut p = vt100::Parser::new(new_rows, new_cols, 0);
869 p.process(b"resized screen content");
870 p.screen().contents_formatted().to_vec()
871 };
872
873 prev_parser.screen_mut().set_size(new_rows, new_cols);
875 prev_parser.process(b"\x1bc");
876 prev_parser.process(&new_formatted_content);
877
878 let mut fresh_parser = vt100::Parser::new(new_rows, new_cols, 0);
880 fresh_parser.process(&new_formatted_content);
881
882 assert_eq!(
883 prev_parser.screen().contents_formatted(),
884 fresh_parser.screen().contents_formatted(),
885 "Reused parser state after set_size + RIS must match fresh parser"
886 );
887 }
888
889 #[test]
890 fn render_frame_outputs_correct_cup_and_sgr() {
891 let mut parser = vt100::Parser::new(4, 8, 0);
892 parser.process(b"\x1b[31mhello\x1b[0m");
893 let screen = parser.screen();
894 let mut buf: Vec<u8> = Vec::new();
895 let (rows, cols) = screen.size();
896 render_frame(&mut buf, screen, rows, cols, false).unwrap();
897 let output = String::from_utf8_lossy(&buf);
898 assert!(output.contains("\x1b[1;1H"));
900 assert!(output.contains("\x1b[2;1H"));
901 assert!(output.contains("\x1b[3;1H"));
902 assert!(output.contains("\x1b[4;1H"));
903 assert!(output.contains("hello"));
905 assert!(
907 output.contains("\x1b[38;5;1m") || output.contains("\x1b[31m"),
908 "Expected red foreground SGR in output: {output:?}"
909 );
910 assert!(!output.contains("\x1b\x1b"), "no double ESC sequences");
912 }
913
914 #[test]
917 fn coalesce_moved_with_moved() {
918 assert!(is_coalescable_mouse(
919 &MouseEventKind::Moved,
920 &KeyModifiers::NONE,
921 &MouseEventKind::Moved,
922 &KeyModifiers::NONE,
923 ));
924 }
925
926 #[test]
927 fn coalesce_drag_same_button() {
928 assert!(is_coalescable_mouse(
929 &MouseEventKind::Drag(MouseButton::Left),
930 &KeyModifiers::NONE,
931 &MouseEventKind::Drag(MouseButton::Left),
932 &KeyModifiers::NONE,
933 ));
934 assert!(is_coalescable_mouse(
935 &MouseEventKind::Drag(MouseButton::Right),
936 &KeyModifiers {
937 shift: true,
938 ..KeyModifiers::NONE
939 },
940 &MouseEventKind::Drag(MouseButton::Right),
941 &KeyModifiers {
942 shift: true,
943 ..KeyModifiers::NONE
944 },
945 ));
946 }
947
948 #[test]
949 fn reject_drag_different_button() {
950 assert!(!is_coalescable_mouse(
951 &MouseEventKind::Drag(MouseButton::Left),
952 &KeyModifiers::NONE,
953 &MouseEventKind::Drag(MouseButton::Right),
954 &KeyModifiers::NONE,
955 ));
956 }
957
958 #[test]
959 fn reject_moved_vs_drag() {
960 assert!(!is_coalescable_mouse(
961 &MouseEventKind::Moved,
962 &KeyModifiers::NONE,
963 &MouseEventKind::Drag(MouseButton::Left),
964 &KeyModifiers::NONE,
965 ));
966 }
967
968 #[test]
969 fn reject_different_modifiers() {
970 assert!(!is_coalescable_mouse(
971 &MouseEventKind::Moved,
972 &KeyModifiers::NONE,
973 &MouseEventKind::Moved,
974 &KeyModifiers {
975 shift: true,
976 ..KeyModifiers::NONE
977 },
978 ));
979 assert!(!is_coalescable_mouse(
980 &MouseEventKind::Drag(MouseButton::Left),
981 &KeyModifiers {
982 control: true,
983 ..KeyModifiers::NONE
984 },
985 &MouseEventKind::Drag(MouseButton::Left),
986 &KeyModifiers::NONE,
987 ));
988 }
989
990 #[test]
991 fn reject_discrete_events() {
992 assert!(!is_coalescable_mouse(
993 &MouseEventKind::Press(MouseButton::Left),
994 &KeyModifiers::NONE,
995 &MouseEventKind::Press(MouseButton::Left),
996 &KeyModifiers::NONE,
997 ));
998 assert!(!is_coalescable_mouse(
999 &MouseEventKind::Release(MouseButton::Left),
1000 &KeyModifiers::NONE,
1001 &MouseEventKind::Moved,
1002 &KeyModifiers::NONE,
1003 ));
1004 assert!(!is_coalescable_mouse(
1005 &MouseEventKind::Moved,
1006 &KeyModifiers::NONE,
1007 &MouseEventKind::ScrollDown,
1008 &KeyModifiers::NONE,
1009 ));
1010 assert!(!is_coalescable_mouse(
1011 &MouseEventKind::ScrollUp,
1012 &KeyModifiers::NONE,
1013 &MouseEventKind::ScrollUp,
1014 &KeyModifiers::NONE,
1015 ));
1016 }
1017
1018 fn coalesce_through(
1023 events: &[Event],
1024 kind: MouseEventKind,
1025 modifiers: KeyModifiers,
1026 ) -> Option<Event> {
1027 let (tx, rx) = crossbeam_channel::bounded::<Event>(events.len());
1028 for e in events.iter().cloned() {
1029 tx.send(e).ok();
1030 }
1031 drop(tx);
1032
1033 let mut result = Event::Mouse(MouseEvent {
1034 kind,
1035 modifiers,
1036 column: 0,
1037 row: 0,
1038 });
1039
1040 if let Event::Mouse(ref mut mouse) = result
1041 && matches!(mouse.kind, MouseEventKind::Moved | MouseEventKind::Drag(_))
1042 {
1043 while let Ok(next) = rx.try_recv() {
1044 match next {
1045 Event::Mouse(ref next_mouse)
1046 if is_coalescable_mouse(
1047 &mouse.kind,
1048 &mouse.modifiers,
1049 &next_mouse.kind,
1050 &next_mouse.modifiers,
1051 ) =>
1052 {
1053 *mouse = *next_mouse;
1054 }
1055 _other => return Some(result),
1056 }
1057 }
1058 }
1059
1060 Some(result)
1061 }
1062
1063 #[test]
1064 fn coalesce_keeps_latest_moved_position() {
1065 let events = vec![
1066 Event::Mouse(MouseEvent {
1067 kind: MouseEventKind::Moved,
1068 modifiers: KeyModifiers::NONE,
1069 column: 5,
1070 row: 5,
1071 }),
1072 Event::Mouse(MouseEvent {
1073 kind: MouseEventKind::Moved,
1074 modifiers: KeyModifiers::NONE,
1075 column: 10,
1076 row: 10,
1077 }),
1078 Event::Mouse(MouseEvent {
1079 kind: MouseEventKind::Moved,
1080 modifiers: KeyModifiers::NONE,
1081 column: 15,
1082 row: 15,
1083 }),
1084 ];
1085 let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1086 let Event::Mouse(m) = result.unwrap() else {
1087 panic!("expected mouse")
1088 };
1089 assert_eq!((m.column, m.row), (15, 15));
1090 }
1091
1092 #[test]
1093 fn coalesce_keeps_latest_drag_position() {
1094 let events = vec![
1095 Event::Mouse(MouseEvent {
1096 kind: MouseEventKind::Drag(MouseButton::Left),
1097 modifiers: KeyModifiers::NONE,
1098 column: 1,
1099 row: 1,
1100 }),
1101 Event::Mouse(MouseEvent {
1102 kind: MouseEventKind::Drag(MouseButton::Left),
1103 modifiers: KeyModifiers::NONE,
1104 column: 2,
1105 row: 2,
1106 }),
1107 ];
1108 let result = coalesce_through(
1109 &events,
1110 MouseEventKind::Drag(MouseButton::Left),
1111 KeyModifiers::NONE,
1112 );
1113 let Event::Mouse(m) = result.unwrap() else {
1114 panic!("expected mouse")
1115 };
1116 assert_eq!((m.column, m.row), (2, 2));
1117 }
1118
1119 #[test]
1120 fn coalesce_stops_at_modifier_change() {
1121 let events = vec![Event::Mouse(MouseEvent {
1122 kind: MouseEventKind::Moved,
1123 modifiers: KeyModifiers {
1124 shift: true,
1125 ..KeyModifiers::NONE
1126 },
1127 column: 99,
1128 row: 99,
1129 })];
1130 let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1131 let Event::Mouse(m) = result.unwrap() else {
1132 panic!("expected mouse")
1133 };
1134 assert_eq!((m.column, m.row), (0, 0));
1137 }
1138
1139 #[test]
1140 fn coalesce_stops_at_non_mouse_event() {
1141 let key = Event::Key(KeyEvent {
1142 code: KeyCode::Char('q'),
1143 kind: KeyKind::Press,
1144 modifiers: KeyModifiers::NONE,
1145 });
1146 let events = vec![key.clone()];
1147 let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1148 let Event::Mouse(m) = result.unwrap() else {
1149 panic!("expected mouse")
1150 };
1151 assert_eq!((m.column, m.row), (0, 0));
1153 }
1154
1155 #[test]
1156 fn coalesce_stops_at_discrete_mouse_event() {
1157 let events = vec![Event::Mouse(MouseEvent {
1158 kind: MouseEventKind::Press(MouseButton::Left),
1159 modifiers: KeyModifiers::NONE,
1160 column: 10,
1161 row: 10,
1162 })];
1163 let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1164 let Event::Mouse(m) = result.unwrap() else {
1165 panic!("expected mouse")
1166 };
1167 assert_eq!((m.column, m.row), (0, 0));
1169 }
1170}
1171
1172#[cfg(test)]
1176#[allow(clippy::type_complexity)]
1177mod snapshot_tests {
1178 use super::*;
1179
1180 fn render_and_capture(pty_bytes: &[u8], rows: u16, cols: u16, clear_display: bool) -> Vec<u8> {
1183 let rt = tokio::runtime::Builder::new_current_thread()
1184 .build()
1185 .expect("tokio rt");
1186 let (push_tx, push_rx) = crossbeam_channel::bounded(16);
1187 let input_writer: Box<dyn FnMut(&[u8]) -> io::Result<()> + Send> = Box::new(|_| Ok(()));
1188 let mut pane = RemotePane::new(
1189 0,
1190 None,
1191 rt.handle().clone(),
1192 cols,
1193 rows,
1194 push_rx,
1195 input_writer,
1196 );
1197 drop(rt); push_tx.send(pty_bytes.to_vec()).ok();
1200 pane.drain_pushes();
1201
1202 let parser = pane.shared_parser();
1203 let parser = parser.lock().unwrap();
1204 let screen = parser.screen();
1205 let (rows, cols) = screen.size();
1206 let mut out = Vec::new();
1207 render_frame(&mut out, screen, rows, cols, clear_display).unwrap();
1208 out
1209 }
1210
1211 fn escape_ansi(bytes: &[u8]) -> String {
1213 let mut out: Vec<u8> = Vec::with_capacity(bytes.len() * 4);
1214 for &b in bytes {
1215 match b {
1216 b'\x1b' => out.extend_from_slice(b"\\x1b"),
1217 b'\n' => out.extend_from_slice(b"\\n"),
1218 b'\r' => out.extend_from_slice(b"\\r"),
1219 b'\t' => out.extend_from_slice(b"\\t"),
1220 0x20..=0x7e => out.push(b),
1221 _ => {
1222 out.push(b'\\');
1223 out.push(b'x');
1224 out.extend_from_slice(&hex_byte(b));
1225 }
1226 }
1227 }
1228 unsafe { String::from_utf8_unchecked(out) }
1230 }
1231
1232 fn hex_byte(b: u8) -> [u8; 2] {
1233 #[inline]
1234 fn hex_nibble(n: u8) -> u8 {
1235 let digit = n & 0x0f;
1236 if digit < 10 {
1237 b'0' + digit
1238 } else {
1239 b'a' + digit - 10
1240 }
1241 }
1242 [hex_nibble(b >> 4), hex_nibble(b)]
1243 }
1244
1245 #[test]
1248 fn snapshot_empty_grid() {
1249 let out = render_and_capture(b"", 4, 8, false);
1250 insta::assert_snapshot!("empty_grid", escape_ansi(&out));
1251 }
1252
1253 #[test]
1254 fn snapshot_basic_text() {
1255 let out = render_and_capture(b"Hello\nWorld", 4, 8, false);
1256 insta::assert_snapshot!("basic_text", escape_ansi(&out));
1257 }
1258
1259 #[test]
1260 fn snapshot_colored_text() {
1261 let out = render_and_capture(b"\x1b[31mred\x1b[1mbold", 4, 8, false);
1262 insta::assert_snapshot!("colored_text", escape_ansi(&out));
1263 }
1264
1265 #[test]
1266 fn snapshot_normal_char_at_margin() {
1267 let out = render_and_capture(b"ABCD", 1, 4, false);
1270 insta::assert_snapshot!("normal_char_at_margin", escape_ansi(&out));
1271 }
1272
1273 #[test]
1274 fn snapshot_clear_display() {
1275 let out = render_and_capture(b"", 4, 8, true);
1276 insta::assert_snapshot!("clear_display", escape_ansi(&out));
1277 }
1278
1279 #[test]
1280 fn snapshot_hidden_cursor() {
1281 let out = render_and_capture(b"\x1b[?25l", 4, 8, false);
1282 insta::assert_snapshot!("hidden_cursor", escape_ansi(&out));
1283 }
1284
1285 #[test]
1286 fn snapshot_color_across_margin() {
1287 let out = render_and_capture(b"\x1b[41mX", 1, 4, false);
1290 insta::assert_snapshot!("color_across_margin", escape_ansi(&out));
1291 }
1292
1293 #[test]
1294 fn snapshot_multi_row_fill() {
1295 let out = render_and_capture(b"ABCDEFGHIJKL", 3, 4, false);
1298 insta::assert_snapshot!("multi_row_fill", escape_ansi(&out));
1299 }
1300
1301 #[test]
1302 fn snapshot_wide_char_margin() {
1303 let out = render_and_capture(
1306 b"B\xe3\x81\x82", 1,
1308 3,
1309 false,
1310 );
1311 insta::assert_snapshot!("wide_char_margin", escape_ansi(&out));
1312 }
1313
1314 #[test]
1315 fn snapshot_wide_char_middle() {
1316 let out = render_and_capture(
1320 b"A\xe3\x81\x82\xe3\x81\x83", 1,
1322 5,
1323 false,
1324 );
1325 insta::assert_snapshot!("wide_char_middle", escape_ansi(&out));
1326 }
1327}