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#[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#[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#[cfg(target_os = "windows")]
107const INITIAL_WAIT_ITERS: usize = 60;
108#[cfg(not(target_os = "windows"))]
109const INITIAL_WAIT_ITERS: usize = 20;
110
111const PTY_OUTPUT_CHANNEL_CAPACITY: usize = 256;
113const CLIPBOARD_CHANNEL_CAPACITY: usize = 64;
115const INPUT_CHANNEL_CAPACITY: usize = 64;
117
118const PREV_TAIL_LEN: usize = 8;
122
123const INITIAL_WAIT_SLEEP_MS: u64 = 50;
125
126const INPUT_POLL_MS: u64 = 50;
129
130const BACKPRESSURE_SLEEP_MS: u64 = 1;
133
134const BRACKETED_PASTE_OVERHEAD: usize = 12;
137
138const RENDER_BUF_CELL_MULTIPLIER: usize = 3;
140
141pub 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
162pub 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
184fn convert_crossterm_event(evt: crossterm::event::Event) -> Option<Event> {
186 term_wm_crossterm_adapter::try_translate_event(evt)
187}
188
189fn 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
209pub fn run_session(socket_path: &str) -> io::Result<()> {
215 #[cfg(windows)]
220 disable_quick_edit();
221
222 #[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 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 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 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 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 let writer = rt.block_on(async {
284 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 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 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 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 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 {
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 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 let (input_tx, input_rx) = crossbeam_channel::bounded::<Event>(INPUT_CHANNEL_CAPACITY);
386
387 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 {
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 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 let resized = apply_pending_resize(&pane.shared_parser());
444 force_render |= resized;
445 clear_display |= resized;
446
447 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 let resized = apply_pending_resize(&pane.shared_parser());
467 force_render |= resized;
468 clear_display |= resized;
469
470 let parser = pane.shared_parser();
472 let mut parser = parser.lock().unwrap();
473 parser.process(&data);
474 None
475 }
476 Err(_) => {
477 None
480 }
481 }
482 }
483 }
484 };
485
486 let has_new_data = pane.drain_pushes() || input_event.is_none();
488
489 while let Ok(text) = clip_rx.try_recv() {
491 let _ = clipboard.set(&text);
492 }
493
494 if sigint.received() {
496 sigint.ack();
497 let _ = pane.write_bytes(&[0x03]);
498 }
499
500 if let Some(mut evt) = input_event {
502 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 if !client.is_connected() {
608 return Err(io::Error::other("connection to session server lost"));
609 }
610
611 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 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 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 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 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 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 #[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 #[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 #[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 #[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 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 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 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 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 assert!(output.contains("hello"));
885 assert!(
887 output.contains("\x1b[38;5;1m") || output.contains("\x1b[31m"),
888 "Expected red foreground SGR in output: {output:?}"
889 );
890 assert!(!output.contains("\x1b\x1b"), "no double ESC sequences");
892 }
893
894 #[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 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 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 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 assert_eq!((m.column, m.row), (0, 0));
1149 }
1150}
1151
1152#[cfg(test)]
1156#[allow(clippy::type_complexity)]
1157mod snapshot_tests {
1158 use super::*;
1159
1160 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); 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 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 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 #[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 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 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 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 let out = render_and_capture(
1286 b"B\xe3\x81\x82", 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 let out = render_and_capture(
1300 b"A\xe3\x81\x82\xe3\x81\x83", 1,
1302 5,
1303 false,
1304 );
1305 insta::assert_snapshot!("wide_char_middle", escape_ansi(&out));
1306 }
1307}