1mod remote_pane;
2
3pub use remote_pane::RemotePane;
4
5use std::io::{self, IsTerminal, Write, stdout};
6#[cfg(unix)]
7use std::os::unix::io::FromRawFd;
8use std::sync::atomic::{AtomicBool, AtomicU16, Ordering};
9use std::sync::{Arc, Mutex};
10use std::time::Duration;
11
12use crossterm::QueueableCommand;
13use crossterm::cursor::{Hide, Show};
14use crossterm::event::{DisableBracketedPaste, EnableBracketedPaste};
15use crossterm::terminal::{
16 EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
17};
18use muxio_rpc_service_endpoint::RpcServiceEndpointInterface;
19use muxio_tokio_mpsc_adapter::ChannelCallerExt;
20use muxio_tokio_rpc_ipc_client::{RpcCallPrebuffered, RpcIpcClient, RpcServiceCallerInterface};
21use portable_pty::PtySize;
22use term_session_muxio_service_definitions::{
23 OnPtyResized, RpcMethodPrebuffered, STREAM_INPUT_METHOD_ID, SUBSCRIBE_OUTPUT_METHOD_ID, Spawn,
24};
25use term_wm_events::{Event, KeyKind, KeyModifiers, MouseEventKind};
26use term_wm_pty_engine::Pane;
27use term_wm_pty_engine::clipboard::{Clipboard, Osc52Extractor};
28use term_wm_pty_engine::input_encoding::{key_to_bytes, mouse_event_to_bytes};
29use term_wm_pty_engine::signal::install_sigint_handler;
30use vt100::{MouseProtocolEncoding, MouseProtocolMode, Parser, Screen};
31
32#[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 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_bytes(key, false);
534 if !bytes.is_empty() {
535 let _ = pane.write_bytes(&bytes);
536 }
537 }
538 Event::Mouse(ref mouse) => {
539 let mouse_active = {
540 let parser = pane.shared_parser();
541 let parser = parser.lock().unwrap();
542 parser.screen().mouse_protocol_mode() != MouseProtocolMode::None
543 };
544 if mouse_active {
545 let bytes = mouse_event_to_bytes(mouse, MouseProtocolEncoding::Sgr);
546 if !bytes.is_empty() {
547 let _ = pane.write_bytes(&bytes);
548 }
549 }
550 }
551 Event::Resize(w, h) => {
552 let size = PtySize {
553 rows: h,
554 cols: w,
555 pixel_width: 0,
556 pixel_height: 0,
557 };
558 if let Err(err) = pane.resize(size) {
559 tracing::warn!(error = %err, "resize request failed on PTY pane");
560 }
561 force_render = true;
562 clear_display = true;
563 }
564 Event::Paste(text) => {
565 let mut wrapped = Vec::with_capacity(text.len() + BRACKETED_PASTE_OVERHEAD);
566 wrapped.extend_from_slice(b"\x1b[200~");
567 wrapped.extend_from_slice(text.as_bytes());
568 wrapped.extend_from_slice(b"\x1b[201~");
569 let _ = pane.write_bytes(&wrapped);
570 }
571 _ => {}
572 }
573 }
574
575 if !client.is_connected() {
577 return Err(io::Error::other("connection to session server lost"));
578 }
579
580 if has_new_data || force_render {
582 let parser = pane.shared_parser();
583 let parser = parser.lock().unwrap();
584 let screen = parser.screen();
585 let (rows, cols) = screen.size();
586 render_frame(&mut out, screen, rows, cols, clear_display)?;
587 }
588
589 if pane.has_exited() {
591 return Ok(());
592 }
593 }
594}
595
596#[derive(Default, PartialEq, Clone, Copy)]
597struct CellStyle {
598 fg: vt100::Color,
599 bg: vt100::Color,
600 bold: bool,
601 dim: bool,
602 italic: bool,
603 underline: bool,
604 inverse: bool,
605}
606
607impl CellStyle {
608 fn from_cell(cell: &vt100::Cell) -> Self {
609 Self {
610 fg: cell.fgcolor(),
611 bg: cell.bgcolor(),
612 bold: cell.bold(),
613 dim: cell.dim(),
614 italic: cell.italic(),
615 underline: cell.underline(),
616 inverse: cell.inverse(),
617 }
618 }
619}
620
621fn apply_sgr(out: &mut dyn Write, style: &CellStyle) -> io::Result<()> {
622 write!(out, "\x1b[0m")?;
623 if style.bold {
624 write!(out, "\x1b[1m")?;
625 }
626 if style.dim {
627 write!(out, "\x1b[2m")?;
628 }
629 if style.italic {
630 write!(out, "\x1b[3m")?;
631 }
632 if style.underline {
633 write!(out, "\x1b[4m")?;
634 }
635 if style.inverse {
636 write!(out, "\x1b[7m")?;
637 }
638 match style.fg {
639 vt100::Color::Idx(i) => write!(out, "\x1b[38;5;{}m", i)?,
640 vt100::Color::Rgb(r, g, b) => write!(out, "\x1b[38;2;{};{};{}m", r, g, b)?,
641 _ => {}
642 }
643 match style.bg {
644 vt100::Color::Idx(i) => write!(out, "\x1b[48;5;{}m", i)?,
645 vt100::Color::Rgb(r, g, b) => write!(out, "\x1b[48;2;{};{};{}m", r, g, b)?,
646 _ => {}
647 }
648 Ok(())
649}
650
651pub fn render_frame(
652 out: &mut dyn Write,
653 screen: &Screen,
654 rows: u16,
655 cols: u16,
656 clear_display: bool,
657) -> io::Result<()> {
658 let mut buf =
659 Vec::with_capacity((rows as usize) * (cols as usize) * RENDER_BUF_CELL_MULTIPLIER);
660 let mut active_style = CellStyle::default();
661
662 buf.extend_from_slice(b"\x1b[?2026h\x1b[?25l\x1b[0m");
664 if clear_display {
665 buf.extend_from_slice(b"\x1b[2J");
666 }
667 buf.extend_from_slice(b"\x1b[?7l");
668
669 for row in 0..rows {
670 write!(buf, "\x1b[{};1H", row + 1)?;
671
672 let mut col: u16 = 0;
673 while col < cols {
674 let cell_opt = screen.cell(row, col);
678 let contents = cell_opt.map_or("", |c| c.contents());
679 let width = if contents.is_empty() {
680 1
681 } else {
682 unicode_width::UnicodeWidthStr::width(contents).max(1) as u16
683 };
684
685 if col + width >= cols {
689 buf.extend_from_slice(b"\x1b[0m\x1b[K");
690 active_style = CellStyle::default();
691 }
692
693 let style = cell_opt.map(CellStyle::from_cell).unwrap_or_default();
694 if style != active_style {
695 apply_sgr(&mut buf, &style)?;
696 active_style = style;
697 }
698
699 if contents.is_empty() {
700 buf.push(b' ');
701 } else {
702 buf.extend_from_slice(contents.as_bytes());
703 }
704
705 col += width;
706 }
707 }
708
709 buf.extend_from_slice(b"\x1b[?7h");
710 buf.extend_from_slice(b"\x1b[0m");
711 let (cur_row, cur_col) = screen.cursor_position();
712 write!(buf, "\x1b[{};{}H", cur_row + 1, cur_col + 1)?;
713 if screen.hide_cursor() {
714 buf.extend_from_slice(b"\x1b[?25l");
715 } else {
716 buf.extend_from_slice(b"\x1b[?25h");
717 }
718 buf.extend_from_slice(b"\x1b[?2026l");
720
721 out.write_all(&buf)?;
722 out.flush()
723}
724
725#[cfg(test)]
726mod tests {
727 use super::*;
728 use std::sync::{Arc, Mutex};
729
730 use term_wm_events::{KeyCode, KeyEvent, MouseButton, MouseEvent};
731
732 struct TestWriter {
733 buf: Arc<Mutex<Vec<u8>>>,
734 }
735
736 impl TestWriter {
737 fn new() -> (Self, Arc<Mutex<Vec<u8>>>) {
738 let buf = Arc::new(Mutex::new(Vec::new()));
739 (Self { buf: buf.clone() }, buf)
740 }
741 }
742
743 impl Write for TestWriter {
744 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
745 self.buf.lock().unwrap().extend_from_slice(buf);
746 Ok(buf.len())
747 }
748 fn flush(&mut self) -> io::Result<()> {
749 Ok(())
750 }
751 }
752
753 #[test]
758 fn init_terminal_writes_bracketed_paste_enable() {
759 let (writer, buf) = TestWriter::new();
760 let _guard = init_terminal(writer).expect("init_terminal");
761 let bytes = buf.lock().unwrap();
762 assert!(
763 bytes
764 .windows(b"\x1b[?2004h".len())
765 .any(|w| w == b"\x1b[?2004h")
766 );
767 }
768
769 #[test]
772 fn terminal_guard_teardown_writes_bracketed_paste_disable() {
773 let (writer, buf) = TestWriter::new();
774 {
775 let _guard = TerminalGuard {
776 writer: Some(writer),
777 };
778 }
779 let bytes = buf.lock().unwrap();
780 assert!(
781 bytes
782 .windows(b"\x1b[?2004l".len())
783 .any(|w| w == b"\x1b[?2004l")
784 );
785 }
786
787 #[test]
790 fn init_and_teardown_roundtrip_contains_both_sequences() {
791 let (writer, buf) = TestWriter::new();
792 let guard = init_terminal(writer).expect("init_terminal");
793 drop(guard);
794 let bytes = buf.lock().unwrap();
795 assert!(
796 bytes
797 .windows(b"\x1b[?2004h".len())
798 .any(|w| w == b"\x1b[?2004h")
799 );
800 assert!(
801 bytes
802 .windows(b"\x1b[?2004l".len())
803 .any(|w| w == b"\x1b[?2004l")
804 );
805 }
806
807 #[test]
810 fn test_prev_parser_resize_sync_matches_fresh_parser() {
811 let mut prev_parser = vt100::Parser::new(24, 80, 0);
812 prev_parser.process(b"initial screen content");
813
814 let (new_rows, new_cols) = (40, 120);
816 let new_formatted_content = {
817 let mut p = vt100::Parser::new(new_rows, new_cols, 0);
818 p.process(b"resized screen content");
819 p.screen().contents_formatted().to_vec()
820 };
821
822 prev_parser.screen_mut().set_size(new_rows, new_cols);
824 prev_parser.process(b"\x1bc");
825 prev_parser.process(&new_formatted_content);
826
827 let mut fresh_parser = vt100::Parser::new(new_rows, new_cols, 0);
829 fresh_parser.process(&new_formatted_content);
830
831 assert_eq!(
832 prev_parser.screen().contents_formatted(),
833 fresh_parser.screen().contents_formatted(),
834 "Reused parser state after set_size + RIS must match fresh parser"
835 );
836 }
837
838 #[test]
839 fn render_frame_outputs_correct_cup_and_sgr() {
840 let mut parser = vt100::Parser::new(4, 8, 0);
841 parser.process(b"\x1b[31mhello\x1b[0m");
842 let screen = parser.screen();
843 let mut buf: Vec<u8> = Vec::new();
844 let (rows, cols) = screen.size();
845 render_frame(&mut buf, screen, rows, cols, false).unwrap();
846 let output = String::from_utf8_lossy(&buf);
847 assert!(output.contains("\x1b[1;1H"));
849 assert!(output.contains("\x1b[2;1H"));
850 assert!(output.contains("\x1b[3;1H"));
851 assert!(output.contains("\x1b[4;1H"));
852 assert!(output.contains("hello"));
854 assert!(
856 output.contains("\x1b[38;5;1m") || output.contains("\x1b[31m"),
857 "Expected red foreground SGR in output: {output:?}"
858 );
859 assert!(!output.contains("\x1b\x1b"), "no double ESC sequences");
861 }
862
863 #[test]
866 fn coalesce_moved_with_moved() {
867 assert!(is_coalescable_mouse(
868 &MouseEventKind::Moved,
869 &KeyModifiers::NONE,
870 &MouseEventKind::Moved,
871 &KeyModifiers::NONE,
872 ));
873 }
874
875 #[test]
876 fn coalesce_drag_same_button() {
877 assert!(is_coalescable_mouse(
878 &MouseEventKind::Drag(MouseButton::Left),
879 &KeyModifiers::NONE,
880 &MouseEventKind::Drag(MouseButton::Left),
881 &KeyModifiers::NONE,
882 ));
883 assert!(is_coalescable_mouse(
884 &MouseEventKind::Drag(MouseButton::Right),
885 &KeyModifiers {
886 shift: true,
887 ..KeyModifiers::NONE
888 },
889 &MouseEventKind::Drag(MouseButton::Right),
890 &KeyModifiers {
891 shift: true,
892 ..KeyModifiers::NONE
893 },
894 ));
895 }
896
897 #[test]
898 fn reject_drag_different_button() {
899 assert!(!is_coalescable_mouse(
900 &MouseEventKind::Drag(MouseButton::Left),
901 &KeyModifiers::NONE,
902 &MouseEventKind::Drag(MouseButton::Right),
903 &KeyModifiers::NONE,
904 ));
905 }
906
907 #[test]
908 fn reject_moved_vs_drag() {
909 assert!(!is_coalescable_mouse(
910 &MouseEventKind::Moved,
911 &KeyModifiers::NONE,
912 &MouseEventKind::Drag(MouseButton::Left),
913 &KeyModifiers::NONE,
914 ));
915 }
916
917 #[test]
918 fn reject_different_modifiers() {
919 assert!(!is_coalescable_mouse(
920 &MouseEventKind::Moved,
921 &KeyModifiers::NONE,
922 &MouseEventKind::Moved,
923 &KeyModifiers {
924 shift: true,
925 ..KeyModifiers::NONE
926 },
927 ));
928 assert!(!is_coalescable_mouse(
929 &MouseEventKind::Drag(MouseButton::Left),
930 &KeyModifiers {
931 control: true,
932 ..KeyModifiers::NONE
933 },
934 &MouseEventKind::Drag(MouseButton::Left),
935 &KeyModifiers::NONE,
936 ));
937 }
938
939 #[test]
940 fn reject_discrete_events() {
941 assert!(!is_coalescable_mouse(
942 &MouseEventKind::Press(MouseButton::Left),
943 &KeyModifiers::NONE,
944 &MouseEventKind::Press(MouseButton::Left),
945 &KeyModifiers::NONE,
946 ));
947 assert!(!is_coalescable_mouse(
948 &MouseEventKind::Release(MouseButton::Left),
949 &KeyModifiers::NONE,
950 &MouseEventKind::Moved,
951 &KeyModifiers::NONE,
952 ));
953 assert!(!is_coalescable_mouse(
954 &MouseEventKind::Moved,
955 &KeyModifiers::NONE,
956 &MouseEventKind::ScrollDown,
957 &KeyModifiers::NONE,
958 ));
959 assert!(!is_coalescable_mouse(
960 &MouseEventKind::ScrollUp,
961 &KeyModifiers::NONE,
962 &MouseEventKind::ScrollUp,
963 &KeyModifiers::NONE,
964 ));
965 }
966
967 fn coalesce_through(
972 events: &[Event],
973 kind: MouseEventKind,
974 modifiers: KeyModifiers,
975 ) -> Option<Event> {
976 let (tx, rx) = crossbeam_channel::bounded::<Event>(events.len());
977 for e in events.iter().cloned() {
978 tx.send(e).ok();
979 }
980 drop(tx);
981
982 let mut result = Event::Mouse(MouseEvent {
983 kind,
984 modifiers,
985 column: 0,
986 row: 0,
987 });
988
989 if let Event::Mouse(ref mut mouse) = result
990 && matches!(mouse.kind, MouseEventKind::Moved | MouseEventKind::Drag(_))
991 {
992 while let Ok(next) = rx.try_recv() {
993 match next {
994 Event::Mouse(ref next_mouse)
995 if is_coalescable_mouse(
996 &mouse.kind,
997 &mouse.modifiers,
998 &next_mouse.kind,
999 &next_mouse.modifiers,
1000 ) =>
1001 {
1002 *mouse = *next_mouse;
1003 }
1004 _other => return Some(result),
1005 }
1006 }
1007 }
1008
1009 Some(result)
1010 }
1011
1012 #[test]
1013 fn coalesce_keeps_latest_moved_position() {
1014 let events = vec![
1015 Event::Mouse(MouseEvent {
1016 kind: MouseEventKind::Moved,
1017 modifiers: KeyModifiers::NONE,
1018 column: 5,
1019 row: 5,
1020 }),
1021 Event::Mouse(MouseEvent {
1022 kind: MouseEventKind::Moved,
1023 modifiers: KeyModifiers::NONE,
1024 column: 10,
1025 row: 10,
1026 }),
1027 Event::Mouse(MouseEvent {
1028 kind: MouseEventKind::Moved,
1029 modifiers: KeyModifiers::NONE,
1030 column: 15,
1031 row: 15,
1032 }),
1033 ];
1034 let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1035 let Event::Mouse(m) = result.unwrap() else {
1036 panic!("expected mouse")
1037 };
1038 assert_eq!((m.column, m.row), (15, 15));
1039 }
1040
1041 #[test]
1042 fn coalesce_keeps_latest_drag_position() {
1043 let events = vec![
1044 Event::Mouse(MouseEvent {
1045 kind: MouseEventKind::Drag(MouseButton::Left),
1046 modifiers: KeyModifiers::NONE,
1047 column: 1,
1048 row: 1,
1049 }),
1050 Event::Mouse(MouseEvent {
1051 kind: MouseEventKind::Drag(MouseButton::Left),
1052 modifiers: KeyModifiers::NONE,
1053 column: 2,
1054 row: 2,
1055 }),
1056 ];
1057 let result = coalesce_through(
1058 &events,
1059 MouseEventKind::Drag(MouseButton::Left),
1060 KeyModifiers::NONE,
1061 );
1062 let Event::Mouse(m) = result.unwrap() else {
1063 panic!("expected mouse")
1064 };
1065 assert_eq!((m.column, m.row), (2, 2));
1066 }
1067
1068 #[test]
1069 fn coalesce_stops_at_modifier_change() {
1070 let events = vec![Event::Mouse(MouseEvent {
1071 kind: MouseEventKind::Moved,
1072 modifiers: KeyModifiers {
1073 shift: true,
1074 ..KeyModifiers::NONE
1075 },
1076 column: 99,
1077 row: 99,
1078 })];
1079 let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1080 let Event::Mouse(m) = result.unwrap() else {
1081 panic!("expected mouse")
1082 };
1083 assert_eq!((m.column, m.row), (0, 0));
1086 }
1087
1088 #[test]
1089 fn coalesce_stops_at_non_mouse_event() {
1090 let key = Event::Key(KeyEvent {
1091 code: KeyCode::Char('q'),
1092 kind: KeyKind::Press,
1093 modifiers: KeyModifiers::NONE,
1094 });
1095 let events = vec![key.clone()];
1096 let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1097 let Event::Mouse(m) = result.unwrap() else {
1098 panic!("expected mouse")
1099 };
1100 assert_eq!((m.column, m.row), (0, 0));
1102 }
1103
1104 #[test]
1105 fn coalesce_stops_at_discrete_mouse_event() {
1106 let events = vec![Event::Mouse(MouseEvent {
1107 kind: MouseEventKind::Press(MouseButton::Left),
1108 modifiers: KeyModifiers::NONE,
1109 column: 10,
1110 row: 10,
1111 })];
1112 let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1113 let Event::Mouse(m) = result.unwrap() else {
1114 panic!("expected mouse")
1115 };
1116 assert_eq!((m.column, m.row), (0, 0));
1118 }
1119}
1120
1121#[cfg(test)]
1125#[allow(clippy::type_complexity)]
1126mod snapshot_tests {
1127 use super::*;
1128
1129 fn render_and_capture(pty_bytes: &[u8], rows: u16, cols: u16, clear_display: bool) -> Vec<u8> {
1132 let rt = tokio::runtime::Builder::new_current_thread()
1133 .build()
1134 .expect("tokio rt");
1135 let (push_tx, push_rx) = crossbeam_channel::bounded(16);
1136 let input_writer: Box<dyn FnMut(&[u8]) -> io::Result<()> + Send> = Box::new(|_| Ok(()));
1137 let mut pane = RemotePane::new(
1138 0,
1139 None,
1140 rt.handle().clone(),
1141 cols,
1142 rows,
1143 push_rx,
1144 input_writer,
1145 );
1146 drop(rt); push_tx.send(pty_bytes.to_vec()).ok();
1149 pane.drain_pushes();
1150
1151 let parser = pane.shared_parser();
1152 let parser = parser.lock().unwrap();
1153 let screen = parser.screen();
1154 let (rows, cols) = screen.size();
1155 let mut out = Vec::new();
1156 render_frame(&mut out, screen, rows, cols, clear_display).unwrap();
1157 out
1158 }
1159
1160 fn escape_ansi(bytes: &[u8]) -> String {
1162 let mut out: Vec<u8> = Vec::with_capacity(bytes.len() * 4);
1163 for &b in bytes {
1164 match b {
1165 b'\x1b' => out.extend_from_slice(b"\\x1b"),
1166 b'\n' => out.extend_from_slice(b"\\n"),
1167 b'\r' => out.extend_from_slice(b"\\r"),
1168 b'\t' => out.extend_from_slice(b"\\t"),
1169 0x20..=0x7e => out.push(b),
1170 _ => {
1171 out.push(b'\\');
1172 out.push(b'x');
1173 out.extend_from_slice(&hex_byte(b));
1174 }
1175 }
1176 }
1177 unsafe { String::from_utf8_unchecked(out) }
1179 }
1180
1181 fn hex_byte(b: u8) -> [u8; 2] {
1182 #[inline]
1183 fn hex_nibble(n: u8) -> u8 {
1184 let digit = n & 0x0f;
1185 if digit < 10 {
1186 b'0' + digit
1187 } else {
1188 b'a' + digit - 10
1189 }
1190 }
1191 [hex_nibble(b >> 4), hex_nibble(b)]
1192 }
1193
1194 #[test]
1197 fn snapshot_empty_grid() {
1198 let out = render_and_capture(b"", 4, 8, false);
1199 insta::assert_snapshot!("empty_grid", escape_ansi(&out));
1200 }
1201
1202 #[test]
1203 fn snapshot_basic_text() {
1204 let out = render_and_capture(b"Hello\nWorld", 4, 8, false);
1205 insta::assert_snapshot!("basic_text", escape_ansi(&out));
1206 }
1207
1208 #[test]
1209 fn snapshot_colored_text() {
1210 let out = render_and_capture(b"\x1b[31mred\x1b[1mbold", 4, 8, false);
1211 insta::assert_snapshot!("colored_text", escape_ansi(&out));
1212 }
1213
1214 #[test]
1215 fn snapshot_normal_char_at_margin() {
1216 let out = render_and_capture(b"ABCD", 1, 4, false);
1219 insta::assert_snapshot!("normal_char_at_margin", escape_ansi(&out));
1220 }
1221
1222 #[test]
1223 fn snapshot_clear_display() {
1224 let out = render_and_capture(b"", 4, 8, true);
1225 insta::assert_snapshot!("clear_display", escape_ansi(&out));
1226 }
1227
1228 #[test]
1229 fn snapshot_hidden_cursor() {
1230 let out = render_and_capture(b"\x1b[?25l", 4, 8, false);
1231 insta::assert_snapshot!("hidden_cursor", escape_ansi(&out));
1232 }
1233
1234 #[test]
1235 fn snapshot_color_across_margin() {
1236 let out = render_and_capture(b"\x1b[41mX", 1, 4, false);
1239 insta::assert_snapshot!("color_across_margin", escape_ansi(&out));
1240 }
1241
1242 #[test]
1243 fn snapshot_multi_row_fill() {
1244 let out = render_and_capture(b"ABCDEFGHIJKL", 3, 4, false);
1247 insta::assert_snapshot!("multi_row_fill", escape_ansi(&out));
1248 }
1249
1250 #[test]
1251 fn snapshot_wide_char_margin() {
1252 let out = render_and_capture(
1255 b"B\xe3\x81\x82", 1,
1257 3,
1258 false,
1259 );
1260 insta::assert_snapshot!("wide_char_margin", escape_ansi(&out));
1261 }
1262
1263 #[test]
1264 fn snapshot_wide_char_middle() {
1265 let out = render_and_capture(
1269 b"A\xe3\x81\x82\xe3\x81\x83", 1,
1271 5,
1272 false,
1273 );
1274 insta::assert_snapshot!("wide_char_middle", escape_ansi(&out));
1275 }
1276}