1mod remote_pane;
2
3pub use remote_pane::RemotePane;
4
5use std::io::{self, IsTerminal, Write, stdout};
6use std::sync::atomic::{AtomicBool, AtomicU16, Ordering};
7use std::sync::{Arc, Mutex};
8use std::time::Duration;
9
10use crossterm::QueueableCommand;
11use crossterm::cursor::{Hide, Show};
12use crossterm::event::{DisableBracketedPaste, EnableBracketedPaste};
13use crossterm::terminal::{
14 EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
15};
16use muxio_rpc_service_endpoint::RpcServiceEndpointInterface;
17use muxio_tokio_mpsc_adapter::ChannelCallerExt;
18use muxio_tokio_rpc_ipc_client::{RpcCallPrebuffered, RpcIpcClient, RpcServiceCallerInterface};
19use portable_pty::PtySize;
20use term_clipboard::{Clipboard, Osc52Extractor};
21use term_session_muxio_service_definitions::{
22 Attach, AttachRequest, OnPtyResized, OnWorkspaceRebind, RpcMethodPrebuffered,
23 STREAM_INPUT_METHOD_ID, SUBSCRIBE_OUTPUT_METHOD_ID, Spawn, SpawnRequest, SpawnResponse,
24 path_wire,
25};
26#[cfg(unix)]
27use term_sys_io::redirect_fd_to_tracing;
28use term_wm_events::{Event, KeyKind, KeyModifiers, MouseEventKind};
29use term_wm_pty_engine::Pane;
30use term_wm_pty_engine::input_encoding::{key_to_bytes, mouse_event_to_bytes};
31use term_wm_pty_engine::signal::install_sigint_handler;
32use term_wm_vt100::{MouseProtocolEncoding, MouseProtocolMode, Parser, Screen};
33
34#[cfg(windows)]
38fn disable_quick_edit() {
39 use windows_sys::Win32::System::Console::{
40 ENABLE_EXTENDED_FLAGS, ENABLE_QUICK_EDIT_MODE, GetConsoleMode, GetStdHandle,
41 STD_INPUT_HANDLE, SetConsoleMode,
42 };
43
44 unsafe {
45 let handle = GetStdHandle(STD_INPUT_HANDLE);
46 let mut mode: u32 = 0;
47 if GetConsoleMode(handle, &mut mode) != 0 {
48 let _ = SetConsoleMode(
49 handle,
50 (mode & !ENABLE_QUICK_EDIT_MODE) | ENABLE_EXTENDED_FLAGS,
51 );
52 }
53 }
54}
55
56#[cfg(target_os = "windows")]
59const INITIAL_WAIT_ITERS: usize = 60;
60#[cfg(not(target_os = "windows"))]
61const INITIAL_WAIT_ITERS: usize = 20;
62
63const PTY_OUTPUT_CHANNEL_CAPACITY: usize = 256;
65const CLIPBOARD_CHANNEL_CAPACITY: usize = 64;
67const INPUT_CHANNEL_CAPACITY: usize = 64;
69
70const PREV_TAIL_LEN: usize = 8;
74
75const INITIAL_WAIT_SLEEP_MS: u64 = 50;
77
78const INPUT_POLL_MS: u64 = 50;
81
82const BACKPRESSURE_SLEEP_MS: u64 = 1;
85
86const BRACKETED_PASTE_OVERHEAD: usize = 12;
89
90const RENDER_BUF_CELL_MULTIPLIER: usize = 3;
92
93const MIN_TERM_COLS: u16 = 2;
97const MIN_TERM_ROWS: u16 = 2;
98
99const FALLBACK_TERM_COLS: u16 = 80;
103const FALLBACK_TERM_ROWS: u16 = 24;
104
105pub fn nested_session_fatal_error(app_name: &str) -> io::Error {
107 io::Error::other(format!(
108 "FATAL: Attempted to run {app_name} inside an existing session environment on the same gateway.\n\
109 Session inception can cause terminal buffer corruption and accidental gateway stops.\n\n\
110 To force nested execution, rerun with:\n \
111 {app_name} --allow-nested [args...]"
112 ))
113}
114
115pub fn is_nested_session_fatal(err: &io::Error) -> bool {
117 err.to_string().contains("FATAL: Attempted to run")
118}
119
120fn should_block_nesting(
124 active_gateway: Option<&str>,
125 target_socket: &str,
126 allow_nested: bool,
127) -> bool {
128 if allow_nested {
129 return false;
130 }
131 match active_gateway {
132 Some(active) => active == target_socket,
133 None => false,
134 }
135}
136
137pub fn init_terminal<W: Write>(mut writer: W) -> io::Result<TerminalGuard<W>> {
145 if std::io::stdin().is_terminal() {
146 enable_raw_mode()?;
147 }
148 writer.queue(EnterAlternateScreen)?;
149 writer.queue(Hide)?;
150 writer.queue(EnableBracketedPaste)?;
151 writer.queue(crossterm::event::EnableMouseCapture)?;
152 #[cfg(windows)]
160 term_wm_crossterm_adapter::set_mouse_capture_with(&mut writer, true)?;
161 writer.flush()?;
162 Ok(TerminalGuard {
163 writer: Some(writer),
164 })
165}
166
167pub struct TerminalGuard<W: Write = std::io::Stdout> {
171 writer: Option<W>,
172}
173
174impl<W: Write> Drop for TerminalGuard<W> {
175 fn drop(&mut self) {
176 if let Some(ref mut writer) = self.writer {
177 let _ = writer.queue(crossterm::event::DisableMouseCapture);
178 #[cfg(windows)]
181 let _ = term_wm_crossterm_adapter::set_mouse_capture_with(writer, false);
182 let _ = writer.queue(DisableBracketedPaste);
183 let _ = writer.queue(Show);
184 let _ = writer.queue(LeaveAlternateScreen);
185 if std::io::stdin().is_terminal() {
186 let _ = disable_raw_mode();
187 }
188 let _ = writer.flush();
189 }
190 }
191}
192
193fn convert_crossterm_event(evt: crossterm::event::Event) -> Option<Event> {
195 term_wm_crossterm_adapter::try_translate_event(evt)
196}
197
198fn attributed_mouse_is_motion(e: &Event) -> bool {
203 matches!(
204 e,
205 Event::Mouse(m) if matches!(m.kind, MouseEventKind::Moved | MouseEventKind::Drag(_))
206 )
207}
208
209fn attributed_mouse_coalescable(a: &Event, b: &Event) -> bool {
212 match (a, b) {
213 (Event::Mouse(am), Event::Mouse(bm)) => {
214 is_coalescable_mouse(&am.kind, &am.modifiers, &bm.kind, &bm.modifiers)
215 }
216 _ => false,
217 }
218}
219
220fn is_coalescable_mouse(
221 a_kind: &MouseEventKind,
222 a_mod: &KeyModifiers,
223 b_kind: &MouseEventKind,
224 b_mod: &KeyModifiers,
225) -> bool {
226 if a_mod != b_mod {
227 return false;
228 }
229 match (a_kind, b_kind) {
230 (MouseEventKind::Moved, MouseEventKind::Moved) => true,
231 (MouseEventKind::Drag(btn1), MouseEventKind::Drag(btn2)) => btn1 == btn2,
232 _ => false,
233 }
234}
235
236fn client_user() -> String {
241 #[cfg(unix)]
242 {
243 unsafe {
244 let pw = libc::getpwuid(libc::getuid());
245 if !pw.is_null() {
246 let name = std::ffi::CStr::from_ptr((*pw).pw_name);
247 if let Ok(s) = name.to_str()
248 && !s.is_empty()
249 {
250 return s.to_string();
251 }
252 }
253 }
254 std::env::var("USER").unwrap_or_default()
255 }
256 #[cfg(windows)]
257 {
258 if let Ok(u) = std::env::var("USERNAME")
259 && !u.is_empty()
260 {
261 return u;
262 }
263 windows_username().unwrap_or_default()
264 }
265 #[cfg(not(any(unix, windows)))]
266 {
267 String::new()
268 }
269}
270
271#[cfg(windows)]
274fn windows_username() -> Option<String> {
275 use std::os::windows::ffi::OsStringExt;
276 use windows_sys::Win32::System::WindowsProgramming::GetUserNameW;
277 let mut buf = [0u16; 256];
278 let mut len = buf.len() as u32;
279 let ok = unsafe { GetUserNameW(buf.as_mut_ptr(), &mut len) };
280 if ok == 0 {
281 return None;
282 }
283 let s = std::ffi::OsString::from_wide(&buf[..len as usize])
284 .to_string_lossy()
285 .into_owned();
286 if s.is_empty() { None } else { Some(s) }
287}
288
289fn client_version() -> String {
292 env!("CARGO_PKG_VERSION").to_string()
293}
294
295fn client_ssh_ip() -> Option<String> {
299 for var in ["SSH_CLIENT", "SSH_CONNECTION"] {
300 if let Ok(v) = std::env::var(var) {
301 let ip = v.split_whitespace().next()?;
302 if !ip.is_empty() {
303 return Some(ip.to_string());
304 }
305 }
306 }
307 None
308}
309
310pub fn run_session(
321 socket_path: &str,
322 channel: &str,
323 cmd: &[String],
324 allow_nested: bool,
325 app_name: &str,
326) -> io::Result<Option<String>> {
327 let host_gateway =
333 std::env::var(term_session_muxio_service_definitions::SESSION_GATEWAY_ENV_VAR).ok();
334 if should_block_nesting(host_gateway.as_deref(), socket_path, allow_nested) {
335 return Err(nested_session_fatal_error(app_name));
336 }
337
338 #[cfg(windows)]
343 disable_quick_edit();
344
345 let rt =
346 tokio::runtime::Runtime::new().map_err(|e| io::Error::other(format!("runtime: {e}")))?;
347
348 let client: Arc<RpcIpcClient> = rt
350 .block_on(RpcIpcClient::new(socket_path))
351 .map_err(|e| io::Error::new(io::ErrorKind::ConnectionRefused, format!("{e:?}")))?;
352
353 let abi_fault = |e: &dyn std::fmt::Display| -> io::Error {
357 io::Error::other(format!(
358 "FATAL: Protocol ABI mismatch. A legacy daemon may be occupying the IPC socket. Manually terminate the daemon process before continuing. (cause: {e})"
359 ))
360 };
361
362 let server_cols = Arc::new(AtomicU16::new(0));
366 let server_rows = Arc::new(AtomicU16::new(0));
367 let resize_pending = Arc::new(AtomicBool::new(false));
368
369 {
370 let cols_ref = Arc::clone(&server_cols);
371 let rows_ref = Arc::clone(&server_rows);
372 let pending_ref = Arc::clone(&resize_pending);
373 rt.block_on(client.get_endpoint().register_prebuffered(
374 OnPtyResized::METHOD_ID,
375 move |payload, _ctx| {
376 let cols_ref = Arc::clone(&cols_ref);
377 let rows_ref = Arc::clone(&rows_ref);
378 let pending_ref = Arc::clone(&pending_ref);
379 async move {
380 let (cols, rows) = OnPtyResized::decode_request(&payload)
381 .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
382 cols_ref.store(cols, Ordering::Relaxed);
383 rows_ref.store(rows, Ordering::Relaxed);
384 pending_ref.store(true, Ordering::Relaxed);
385 OnPtyResized::encode_response(())
386 .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
387 }
388 },
389 ))
390 .map_err(|e| io::Error::other(format!("register OnPtyResized: {e:?}")))?;
391 }
392
393 let rebind_target: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
396
397 {
398 let target_ref = Arc::clone(&rebind_target);
399 rt.block_on(client.get_endpoint().register_prebuffered(
400 OnWorkspaceRebind::METHOD_ID,
401 move |payload, _ctx| {
402 let target_ref = Arc::clone(&target_ref);
403 async move {
404 let req = OnWorkspaceRebind::decode_request(&payload)
405 .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
406 *target_ref.lock().unwrap_or_else(|err| err.into_inner()) = Some(req.target);
407 OnWorkspaceRebind::encode_response(())
408 .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
409 }
410 },
411 ))
412 .map_err(|e| io::Error::other(format!("register OnWorkspaceRebind: {e:?}")))?;
413 }
414
415 let (push_tx, push_rx) = crossbeam_channel::bounded::<Vec<u8>>(PTY_OUTPUT_CHANNEL_CAPACITY);
419 let (clip_tx, clip_rx) = crossbeam_channel::bounded::<String>(CLIPBOARD_CHANNEL_CAPACITY);
420
421 let (term_cols, term_rows) = match crossterm::terminal::size() {
428 Ok((c, r)) => (c.max(MIN_TERM_COLS), r.max(MIN_TERM_ROWS)),
429 Err(_) => (FALLBACK_TERM_COLS, FALLBACK_TERM_ROWS),
430 };
431 let hostname = hostname::get()
432 .map(|h| h.to_string_lossy().into_owned())
433 .unwrap_or_else(|_| "unknown".to_string());
434
435 let (actual_cols, actual_rows) = rt.block_on(async {
436 let conn_id = Attach::call(
439 &*client,
440 AttachRequest {
441 channel: channel.to_string(),
442 hostname,
443 pid: std::process::id() as u64,
444 user: client_user(),
445 version: client_version(),
446 ssh_ip: client_ssh_ip(),
447 },
448 )
449 .await
450 .map_err(|e| abi_fault(&e))?;
451 let cmd = if cmd.is_empty() {
453 None
454 } else {
455 Some(cmd.to_vec())
456 };
457 let launch_cwd = std::env::current_dir().ok().map(path_wire::encode_path);
463 let SpawnResponse {
464 id: _session_id,
465 cols: actual_cols,
466 rows: actual_rows,
467 ..
468 } = Spawn::call(
469 &*client,
470 SpawnRequest {
471 cmd,
472 cols: term_cols,
473 rows: term_rows,
474 cwd: launch_cwd,
475 },
476 )
477 .await
478 .map_err(|e| abi_fault(&e))?;
479 let _ = conn_id;
480 Ok::<(u16, u16), io::Error>((actual_cols, actual_rows))
481 })?;
482
483 let writer = rt.block_on(async {
485 let (_, mut reader) = client
488 .open_channel(SUBSCRIBE_OUTPUT_METHOD_ID, 0)
489 .await
490 .map_err(|e| io::Error::other(format!("subscribe: {e:?}")))?;
491
492 rt.spawn(async move {
496 let mut osc52 = Osc52Extractor::new();
497 let mut prev_tail: [u8; PREV_TAIL_LEN] = [0; PREV_TAIL_LEN];
498
499 while let Some(chunk) = reader.recv().await {
500 if let Ok(mut data) = chunk {
501 if let Some(text) = osc52.push(&data, &prev_tail) {
502 let _ = clip_tx.try_send(text);
503 }
504
505 let n = data.len();
506 if n >= PREV_TAIL_LEN {
507 prev_tail.copy_from_slice(&data[n - PREV_TAIL_LEN..n]);
508 } else if n > 0 {
509 prev_tail.rotate_left(n);
510 prev_tail[PREV_TAIL_LEN - n..].copy_from_slice(&data[..n]);
511 }
512
513 while let Err(crossbeam_channel::TrySendError::Full(pending)) =
516 push_tx.try_send(data)
517 {
518 data = pending;
519 tokio::time::sleep(Duration::from_millis(BACKPRESSURE_SLEEP_MS)).await;
520 }
521 } else {
522 break;
523 }
524 }
525 if let Some(text) = osc52.finish() {
528 let _ = clip_tx.try_send(text);
529 }
530 });
531
532 let (writer, _) = client
535 .open_channel(STREAM_INPUT_METHOD_ID, 0)
536 .await
537 .map_err(|e| io::Error::other(format!("stream input: {e:?}")))?;
538
539 Ok::<_, io::Error>(writer)
540 })?;
541
542 let input_writer = Box::new(move |data: &[u8]| -> io::Result<()> {
543 writer
544 .send(data.to_vec())
545 .map_err(|e| io::Error::other(e.to_string()))?;
546 Ok(())
547 });
548
549 let mut pane = RemotePane::new(
550 1u64,
551 Some(client.clone()),
552 rt.handle().clone(),
553 term_cols,
554 term_rows,
555 push_rx.clone(),
556 input_writer,
557 );
558
559 let (attributed_tx, mut attributed_rx) = tokio::sync::mpsc::channel::<
562 term_session_muxio_service_definitions::SendAttributedInputRequest,
563 >(1024);
564 {
565 let client_clone = client.clone();
566 rt.spawn(async move {
567 use muxio_rpc_service_caller::prebuffered::RpcCallPrebuffered;
568 use term_session_muxio_service_definitions::SendAttributedInput;
569
570 let mut pending_req = None;
571 loop {
572 let mut req = match pending_req.take() {
573 Some(r) => r,
574 None => match attributed_rx.recv().await {
575 Some(r) => r,
576 None => break,
577 },
578 };
579
580 while attributed_mouse_is_motion(&req.event) {
585 match attributed_rx.try_recv() {
586 Ok(next_req)
587 if attributed_mouse_coalescable(&req.event, &next_req.event) =>
588 {
589 req = next_req;
590 }
591 Ok(next_req) => {
592 pending_req = Some(next_req);
593 break;
594 }
595 Err(_) => break,
596 }
597 }
598 let _ = SendAttributedInput::call(&*client_clone, req).await;
599 }
600 });
601 }
602
603 for _ in 0..INITIAL_WAIT_ITERS {
605 pane.drain_pushes();
606 let parser = pane.shared_parser();
607 let parser = parser.lock().unwrap_or_else(|e| e.into_inner());
608 if !parser.screen().contents_formatted().is_empty() {
609 break;
610 }
611 drop(parser);
612 std::thread::sleep(Duration::from_millis(INITIAL_WAIT_SLEEP_MS));
613 }
614
615 {
617 let parser = pane.shared_parser();
618 let mut parser_lk = parser.lock().unwrap_or_else(|e| e.into_inner());
619 let (cur_rows, cur_cols) = parser_lk.screen().size();
620 if actual_cols != cur_cols || actual_rows != cur_rows {
621 parser_lk.screen_mut().set_size(actual_rows, actual_cols);
622 }
623 drop(parser_lk);
624 }
625
626 #[cfg(unix)]
637 let _ = redirect_fd_to_tracing(libc::STDERR_FILENO, true);
638 let _guard = init_terminal(stdout())?;
639 let mut out = stdout();
640
641 let mut clipboard = Clipboard::new();
642 let sigint = install_sigint_handler()?;
643
644 let (input_tx, input_rx) = crossbeam_channel::bounded::<Event>(INPUT_CHANNEL_CAPACITY);
646
647 std::thread::Builder::new()
651 .name("crossterm-input".into())
652 .spawn(move || {
653 loop {
654 match crossterm::event::poll(Duration::from_millis(INPUT_POLL_MS)) {
655 Ok(true) => {
656 if let Ok(crossterm_evt) = crossterm::event::read()
657 && let Some(e) = convert_crossterm_event(crossterm_evt)
658 && input_tx.send(e).is_err()
659 {
660 break;
661 }
662 }
663 Ok(false) => continue,
664 Err(_) => break,
665 }
666 }
667 })
668 .map_err(|e| io::Error::other(format!("spawn input thread: {e}")))?;
669
670 {
672 let parser = pane.shared_parser();
673 let parser = parser.lock().unwrap_or_else(|e| e.into_inner());
674 let screen = parser.screen();
675 let (rows, cols) = screen.size();
676 render_frame(&mut out, screen, rows, cols, false)?;
677 }
678
679 let mut pending_input: Option<Event> = None;
680 loop {
681 let mut force_render = false;
682 let mut clear_display = false;
683
684 let apply_pending_resize = |shared_parser: &Arc<Mutex<Parser>>| -> bool {
687 if resize_pending.swap(false, Ordering::Relaxed) {
688 let cols = server_cols.load(Ordering::Relaxed);
689 let rows = server_rows.load(Ordering::Relaxed);
690 if cols > 0 && rows > 0 {
691 let mut parser_lk = shared_parser.lock().unwrap_or_else(|e| e.into_inner());
692 let (cur_rows, cur_cols) = parser_lk.screen().size();
693 if cur_cols != cols || cur_rows != rows {
694 parser_lk.screen_mut().set_size(rows, cols);
695 return true;
696 }
697 }
698 }
699 false
700 };
701
702 let resized = apply_pending_resize(&pane.shared_parser());
704 force_render |= resized;
705 clear_display |= resized;
706
707 if let Some(target) = rebind_target
711 .lock()
712 .unwrap_or_else(|err| err.into_inner())
713 .take()
714 {
715 return Ok(Some(target));
716 }
717
718 let input_event = if let Some(evt) = pending_input.take() {
721 Some(evt)
722 } else {
723 crossbeam_channel::select! {
724 recv(input_rx) -> msg => {
725 match msg {
726 Ok(evt) => Some(evt),
727 Err(_) => return Err(io::Error::other("input thread died")),
728 }
729 }
730 recv(push_rx) -> msg => {
731 match msg {
732 Ok(data) => {
733 let resized = apply_pending_resize(&pane.shared_parser());
738 force_render |= resized;
739 clear_display |= resized;
740
741 let parser = pane.shared_parser();
743 let mut parser = parser.lock().unwrap_or_else(|e| e.into_inner());
744 parser.process(&data);
745 None
746 }
747 Err(_) => {
748 None
751 }
752 }
753 }
754 recv(crossbeam_channel::after(Duration::from_millis(100))) -> _ => {
755 force_render = true;
758 None
759 }
760 }
761 };
762
763 let has_new_data = pane.drain_pushes() || input_event.is_none();
765
766 while let Ok(text) = clip_rx.try_recv() {
768 clipboard.set(&text);
769 }
770
771 if sigint.received() {
773 sigint.ack();
774 let _ = pane.write_bytes(&[0x03]);
775 }
776
777 if let Some(mut evt) = input_event {
779 if let Event::Mouse(ref mut mouse) = evt
784 && matches!(mouse.kind, MouseEventKind::Moved | MouseEventKind::Drag(_))
785 {
786 while let Ok(next_evt) = input_rx.try_recv() {
787 match next_evt {
788 Event::Mouse(ref next_mouse)
789 if is_coalescable_mouse(
790 &mouse.kind,
791 &mouse.modifiers,
792 &next_mouse.kind,
793 &next_mouse.modifiers,
794 ) =>
795 {
796 *mouse = *next_mouse;
797 }
798 other => {
799 pending_input = Some(other);
800 break;
801 }
802 }
803 }
804 }
805
806 {
810 use term_session_muxio_service_definitions::SendAttributedInputRequest;
811 let _ = attributed_tx.try_send(SendAttributedInputRequest {
812 channel: channel.to_string(),
813 event: evt.clone(),
814 });
815 }
816
817 match evt {
818 Event::Key(ref key)
819 if key.kind == KeyKind::Press || key.kind == KeyKind::Repeat =>
820 {
821 let bytes = key_to_bytes(key, false);
822 if !bytes.is_empty() {
823 let _ = pane.write_bytes(&bytes);
824 }
825 }
826 Event::Mouse(ref mouse) => {
827 let mouse_active = {
828 let parser = pane.shared_parser();
829 let parser = parser.lock().unwrap_or_else(|e| e.into_inner());
830 parser.screen().mouse_protocol_mode() != MouseProtocolMode::None
831 };
832 if mouse_active {
833 let bytes = mouse_event_to_bytes(mouse, MouseProtocolEncoding::Sgr);
834 if !bytes.is_empty() {
835 let _ = pane.write_bytes(&bytes);
836 }
837 }
838 }
839 Event::Resize(w, h) => {
840 let size = PtySize {
841 rows: h,
842 cols: w,
843 pixel_width: 0,
844 pixel_height: 0,
845 };
846 if let Err(err) = pane.resize(size) {
847 tracing::warn!(error = %err, "resize request failed on PTY pane");
848 }
849 force_render = true;
850 clear_display = true;
851 }
852 Event::Paste(text) => {
853 let mut wrapped = Vec::with_capacity(text.len() + BRACKETED_PASTE_OVERHEAD);
854 wrapped.extend_from_slice(b"\x1b[200~");
855 wrapped.extend_from_slice(text.as_bytes());
856 wrapped.extend_from_slice(b"\x1b[201~");
857 let _ = pane.write_bytes(&wrapped);
858 }
859 _ => {}
860 }
861 }
862
863 if !client.is_connected() {
865 return Err(io::Error::other("connection to session server lost"));
866 }
867
868 if has_new_data || force_render {
870 let parser = pane.shared_parser();
871 let parser = parser.lock().unwrap_or_else(|e| e.into_inner());
872 let screen = parser.screen();
873 let (rows, cols) = screen.size();
874 render_frame(&mut out, screen, rows, cols, clear_display)?;
875 }
876
877 if pane.has_exited() {
879 return Ok(None);
880 }
881 }
882}
883
884#[derive(Default, PartialEq, Clone, Copy)]
885struct CellStyle {
886 fg: term_wm_vt100::Color,
887 bg: term_wm_vt100::Color,
888 bold: bool,
889 dim: bool,
890 italic: bool,
891 underline: bool,
892 inverse: bool,
893}
894
895impl CellStyle {
896 fn from_cell(cell: &term_wm_vt100::Cell) -> Self {
897 Self {
898 fg: cell.fgcolor(),
899 bg: cell.bgcolor(),
900 bold: cell.bold(),
901 dim: cell.dim(),
902 italic: cell.italic(),
903 underline: cell.underline(),
904 inverse: cell.inverse(),
905 }
906 }
907}
908
909fn apply_sgr(out: &mut dyn Write, style: &CellStyle) -> io::Result<()> {
910 write!(out, "\x1b[0m")?;
911 if style.bold {
912 write!(out, "\x1b[1m")?;
913 }
914 if style.dim {
915 write!(out, "\x1b[2m")?;
916 }
917 if style.italic {
918 write!(out, "\x1b[3m")?;
919 }
920 if style.underline {
921 write!(out, "\x1b[4m")?;
922 }
923 if style.inverse {
924 write!(out, "\x1b[7m")?;
925 }
926 match style.fg {
927 term_wm_vt100::Color::Idx(i) => write!(out, "\x1b[38;5;{}m", i)?,
928 term_wm_vt100::Color::Rgb(r, g, b) => write!(out, "\x1b[38;2;{};{};{}m", r, g, b)?,
929 _ => {}
930 }
931 match style.bg {
932 term_wm_vt100::Color::Idx(i) => write!(out, "\x1b[48;5;{}m", i)?,
933 term_wm_vt100::Color::Rgb(r, g, b) => write!(out, "\x1b[48;2;{};{};{}m", r, g, b)?,
934 _ => {}
935 }
936 Ok(())
937}
938
939pub fn render_frame(
940 out: &mut dyn Write,
941 screen: &Screen,
942 rows: u16,
943 cols: u16,
944 clear_display: bool,
945) -> io::Result<()> {
946 let mut buf =
947 Vec::with_capacity((rows as usize) * (cols as usize) * RENDER_BUF_CELL_MULTIPLIER);
948 let mut active_style = CellStyle::default();
949
950 buf.extend_from_slice(b"\x1b[?2026h\x1b[?25l\x1b[0m");
952 if clear_display {
953 buf.extend_from_slice(b"\x1b[2J");
954 }
955 buf.extend_from_slice(b"\x1b[?7l");
956
957 for row in 0..rows {
958 write!(buf, "\x1b[{};1H", row + 1)?;
959
960 let mut col: u16 = 0;
961 while col < cols {
962 let cell_opt = screen.cell(row, col);
966 let contents = cell_opt.map_or("", |c| c.contents());
967 let width = if contents.is_empty() {
968 1
969 } else {
970 unicode_width::UnicodeWidthStr::width(contents).max(1) as u16
971 };
972
973 if col + width >= cols {
977 buf.extend_from_slice(b"\x1b[0m\x1b[K");
978 active_style = CellStyle::default();
979 }
980
981 let style = cell_opt.map(CellStyle::from_cell).unwrap_or_default();
982 if style != active_style {
983 apply_sgr(&mut buf, &style)?;
984 active_style = style;
985 }
986
987 if contents.is_empty() {
988 buf.push(b' ');
989 } else {
990 buf.extend_from_slice(contents.as_bytes());
991 }
992
993 col += width;
994 }
995 }
996
997 buf.extend_from_slice(b"\x1b[?7h");
998 buf.extend_from_slice(b"\x1b[0m");
999 let (cur_row, cur_col) = screen.cursor_position();
1000 write!(buf, "\x1b[{};{}H", cur_row + 1, cur_col + 1)?;
1001 if screen.hide_cursor() {
1002 buf.extend_from_slice(b"\x1b[?25l");
1003 } else {
1004 buf.extend_from_slice(b"\x1b[?25h");
1005 }
1006 buf.extend_from_slice(b"\x1b[?2026l");
1008
1009 out.write_all(&buf)?;
1010 out.flush()
1011}
1012
1013#[allow(clippy::unwrap_used)]
1014#[cfg(test)]
1015mod tests {
1016 use super::*;
1017 use std::sync::{Arc, Mutex};
1018
1019 use term_wm_events::{KeyCode, KeyEvent, MouseButton, MouseEvent};
1020
1021 #[test]
1022 fn should_block_nesting_truth_table() {
1023 assert!(
1025 should_block_nesting(
1026 Some("term-wm/prod/alice/gateway"),
1027 "term-wm/prod/alice/gateway",
1028 false
1029 ),
1030 "same gateway, no override -> block"
1031 );
1032 assert!(
1034 !should_block_nesting(
1035 Some("term-wm/prod/alice/gateway"),
1036 "term-wm/dev/alice/gateway",
1037 false
1038 ),
1039 "different gateway -> proceed"
1040 );
1041 assert!(
1043 !should_block_nesting(
1044 Some("term-wm/prod/alice/gateway"),
1045 "term-wm/prod/alice/gateway",
1046 true
1047 ),
1048 "same gateway, allow -> proceed"
1049 );
1050 assert!(
1052 !should_block_nesting(None, "any-gateway", false),
1053 "no gateway set -> proceed"
1054 );
1055 assert!(
1057 !should_block_nesting(None, "any-gateway", true),
1058 "no gateway, allow -> proceed"
1059 );
1060 }
1061
1062 #[test]
1063 fn nested_session_fatal_error_brands_with_app_name() {
1064 let err = nested_session_fatal_error("my-app");
1065 let msg = err.to_string();
1066 assert!(msg.contains("my-app"), "error must contain app name: {msg}");
1067 assert!(
1068 msg.contains("--allow-nested"),
1069 "error must recommend --allow-nested: {msg}"
1070 );
1071 assert!(
1072 msg.starts_with("FATAL:"),
1073 "error must start with FATAL: {msg}"
1074 );
1075 }
1076
1077 #[test]
1078 fn is_nested_session_fatal_detects_fatal_errors() {
1079 let fatal = nested_session_fatal_error("term-wm");
1080 assert!(is_nested_session_fatal(&fatal), "must detect fatal error");
1081
1082 let other = io::Error::other("some other error");
1083 assert!(
1084 !is_nested_session_fatal(&other),
1085 "must not false-positive on other errors"
1086 );
1087
1088 let empty = io::Error::other("");
1089 assert!(
1090 !is_nested_session_fatal(&empty),
1091 "must not false-positive on empty message"
1092 );
1093 }
1094
1095 struct TestWriter {
1096 buf: Arc<Mutex<Vec<u8>>>,
1097 }
1098
1099 impl TestWriter {
1100 fn new() -> (Self, Arc<Mutex<Vec<u8>>>) {
1101 let buf = Arc::new(Mutex::new(Vec::new()));
1102 (Self { buf: buf.clone() }, buf)
1103 }
1104 }
1105
1106 impl Write for TestWriter {
1107 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
1108 self.buf.lock().unwrap().extend_from_slice(buf);
1109 Ok(buf.len())
1110 }
1111 fn flush(&mut self) -> io::Result<()> {
1112 Ok(())
1113 }
1114 }
1115
1116 #[test]
1121 fn init_terminal_writes_bracketed_paste_enable() {
1122 let (writer, buf) = TestWriter::new();
1123 let _guard = init_terminal(writer).expect("init_terminal");
1124 let bytes = buf.lock().unwrap();
1125 assert!(
1126 bytes
1127 .windows(b"\x1b[?2004h".len())
1128 .any(|w| w == b"\x1b[?2004h")
1129 );
1130 }
1131
1132 #[test]
1137 fn init_terminal_writes_mouse_enable_ansi() {
1138 let (writer, buf) = TestWriter::new();
1139 let _guard = init_terminal(writer).expect("init_terminal");
1140 let bytes = buf.lock().unwrap();
1141 assert!(
1142 bytes
1143 .windows(b"\x1b[?1000h".len())
1144 .any(|w| w == b"\x1b[?1000h"),
1145 "init_terminal must emit the mouse-enable ANSI so a host routes mouse input"
1146 );
1147 }
1148
1149 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
1155 static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1156 LOCK.lock().unwrap_or_else(|e| e.into_inner())
1157 }
1158
1159 #[test]
1160 fn client_ssh_ip_from_ssh_client() {
1161 let _guard = env_lock();
1162 unsafe {
1163 std::env::set_var("SSH_CLIENT", "192.168.1.50 54321 22");
1164 std::env::remove_var("SSH_CONNECTION");
1165 }
1166 assert_eq!(client_ssh_ip().as_deref(), Some("192.168.1.50"));
1167 unsafe {
1168 std::env::remove_var("SSH_CLIENT");
1169 }
1170 }
1171
1172 #[test]
1173 fn client_ssh_ip_from_ssh_connection_fallback() {
1174 let _guard = env_lock();
1175 unsafe {
1176 std::env::remove_var("SSH_CLIENT");
1177 std::env::set_var("SSH_CONNECTION", "10.0.0.7 48000 10.0.0.1 22");
1178 }
1179 assert_eq!(client_ssh_ip().as_deref(), Some("10.0.0.7"));
1180 unsafe {
1181 std::env::remove_var("SSH_CONNECTION");
1182 }
1183 }
1184
1185 #[test]
1186 fn client_ssh_ip_ssh_client_wins_over_connection() {
1187 let _guard = env_lock();
1188 unsafe {
1189 std::env::set_var("SSH_CLIENT", "1.2.3.4 1000 22");
1190 std::env::set_var("SSH_CONNECTION", "9.9.9.9 2000 1.1.1.1 22");
1191 }
1192 assert_eq!(client_ssh_ip().as_deref(), Some("1.2.3.4"));
1193 unsafe {
1194 std::env::remove_var("SSH_CLIENT");
1195 std::env::remove_var("SSH_CONNECTION");
1196 }
1197 }
1198
1199 #[test]
1200 fn client_ssh_ip_none_when_local() {
1201 let _guard = env_lock();
1202 unsafe {
1203 std::env::remove_var("SSH_CLIENT");
1204 std::env::remove_var("SSH_CONNECTION");
1205 }
1206 assert_eq!(client_ssh_ip(), None);
1207 }
1208
1209 #[test]
1210 fn client_version_matches_package() {
1211 assert_eq!(client_version(), env!("CARGO_PKG_VERSION"));
1212 }
1213
1214 #[test]
1215 fn client_user_non_empty() {
1216 assert!(!client_user().is_empty(), "client user must resolve");
1217 }
1218
1219 #[test]
1220 #[cfg(windows)]
1221 fn client_user_prefers_username_env_when_set() {
1222 let _guard = env_lock();
1223 unsafe {
1224 std::env::set_var("USERNAME", "win-test-user");
1225 }
1226 assert_eq!(client_user(), "win-test-user");
1227 unsafe {
1228 std::env::remove_var("USERNAME");
1229 }
1230 }
1231
1232 #[test]
1233 #[cfg(windows)]
1234 fn client_user_falls_back_to_getusername_when_env_absent() {
1235 let _guard = env_lock();
1236 unsafe {
1237 std::env::remove_var("USERNAME");
1238 }
1239 assert!(
1242 !client_user().is_empty(),
1243 "GetUserNameW fallback must resolve a user"
1244 );
1245 }
1246
1247 #[test]
1250 fn terminal_guard_teardown_writes_bracketed_paste_disable() {
1251 let (writer, buf) = TestWriter::new();
1252 {
1253 let _guard = TerminalGuard {
1254 writer: Some(writer),
1255 };
1256 }
1257 let bytes = buf.lock().unwrap();
1258 assert!(
1259 bytes
1260 .windows(b"\x1b[?2004l".len())
1261 .any(|w| w == b"\x1b[?2004l")
1262 );
1263 }
1264
1265 #[test]
1268 fn terminal_guard_teardown_writes_mouse_disable_ansi() {
1269 let (writer, buf) = TestWriter::new();
1270 {
1271 let _guard = TerminalGuard {
1272 writer: Some(writer),
1273 };
1274 }
1275 let bytes = buf.lock().unwrap();
1276 assert!(
1277 bytes
1278 .windows(b"\x1b[?1000l".len())
1279 .any(|w| w == b"\x1b[?1000l"),
1280 "TerminalGuard teardown must emit the mouse-disable ANSI"
1281 );
1282 }
1283
1284 #[test]
1287 fn init_and_teardown_roundtrip_contains_both_sequences() {
1288 let (writer, buf) = TestWriter::new();
1289 let guard = init_terminal(writer).expect("init_terminal");
1290 drop(guard);
1291 let bytes = buf.lock().unwrap();
1292 assert!(
1293 bytes
1294 .windows(b"\x1b[?2004h".len())
1295 .any(|w| w == b"\x1b[?2004h")
1296 );
1297 assert!(
1298 bytes
1299 .windows(b"\x1b[?2004l".len())
1300 .any(|w| w == b"\x1b[?2004l")
1301 );
1302 }
1303
1304 #[test]
1307 fn test_prev_parser_resize_sync_matches_fresh_parser() {
1308 let mut prev_parser = term_wm_vt100::Parser::new(24, 80, 0);
1309 prev_parser.process(b"initial screen content");
1310
1311 let (new_rows, new_cols) = (40, 120);
1313 let new_formatted_content = {
1314 let mut p = term_wm_vt100::Parser::new(new_rows, new_cols, 0);
1315 p.process(b"resized screen content");
1316 p.screen().contents_formatted().to_vec()
1317 };
1318
1319 prev_parser.screen_mut().set_size(new_rows, new_cols);
1321 prev_parser.process(b"\x1bc");
1322 prev_parser.process(&new_formatted_content);
1323
1324 let mut fresh_parser = term_wm_vt100::Parser::new(new_rows, new_cols, 0);
1326 fresh_parser.process(&new_formatted_content);
1327
1328 assert_eq!(
1329 prev_parser.screen().contents_formatted(),
1330 fresh_parser.screen().contents_formatted(),
1331 "Reused parser state after set_size + RIS must match fresh parser"
1332 );
1333 }
1334
1335 #[test]
1336 fn render_frame_outputs_correct_cup_and_sgr() {
1337 let mut parser = term_wm_vt100::Parser::new(4, 8, 0);
1338 parser.process(b"\x1b[31mhello\x1b[0m");
1339 let screen = parser.screen();
1340 let mut buf: Vec<u8> = Vec::new();
1341 let (rows, cols) = screen.size();
1342 render_frame(&mut buf, screen, rows, cols, false).unwrap();
1343 let output = String::from_utf8_lossy(&buf);
1344 assert!(output.contains("\x1b[1;1H"));
1346 assert!(output.contains("\x1b[2;1H"));
1347 assert!(output.contains("\x1b[3;1H"));
1348 assert!(output.contains("\x1b[4;1H"));
1349 assert!(output.contains("hello"));
1351 assert!(
1353 output.contains("\x1b[38;5;1m") || output.contains("\x1b[31m"),
1354 "Expected red foreground SGR in output: {output:?}"
1355 );
1356 assert!(!output.contains("\x1b\x1b"), "no double ESC sequences");
1358 }
1359
1360 #[test]
1363 fn coalesce_moved_with_moved() {
1364 assert!(is_coalescable_mouse(
1365 &MouseEventKind::Moved,
1366 &KeyModifiers::NONE,
1367 &MouseEventKind::Moved,
1368 &KeyModifiers::NONE,
1369 ));
1370 }
1371
1372 #[test]
1373 fn coalesce_drag_same_button() {
1374 assert!(is_coalescable_mouse(
1375 &MouseEventKind::Drag(MouseButton::Left),
1376 &KeyModifiers::NONE,
1377 &MouseEventKind::Drag(MouseButton::Left),
1378 &KeyModifiers::NONE,
1379 ));
1380 assert!(is_coalescable_mouse(
1381 &MouseEventKind::Drag(MouseButton::Right),
1382 &KeyModifiers {
1383 shift: true,
1384 ..KeyModifiers::NONE
1385 },
1386 &MouseEventKind::Drag(MouseButton::Right),
1387 &KeyModifiers {
1388 shift: true,
1389 ..KeyModifiers::NONE
1390 },
1391 ));
1392 }
1393
1394 #[test]
1395 fn reject_drag_different_button() {
1396 assert!(!is_coalescable_mouse(
1397 &MouseEventKind::Drag(MouseButton::Left),
1398 &KeyModifiers::NONE,
1399 &MouseEventKind::Drag(MouseButton::Right),
1400 &KeyModifiers::NONE,
1401 ));
1402 }
1403
1404 #[test]
1405 fn reject_moved_vs_drag() {
1406 assert!(!is_coalescable_mouse(
1407 &MouseEventKind::Moved,
1408 &KeyModifiers::NONE,
1409 &MouseEventKind::Drag(MouseButton::Left),
1410 &KeyModifiers::NONE,
1411 ));
1412 }
1413
1414 #[test]
1415 fn reject_different_modifiers() {
1416 assert!(!is_coalescable_mouse(
1417 &MouseEventKind::Moved,
1418 &KeyModifiers::NONE,
1419 &MouseEventKind::Moved,
1420 &KeyModifiers {
1421 shift: true,
1422 ..KeyModifiers::NONE
1423 },
1424 ));
1425 assert!(!is_coalescable_mouse(
1426 &MouseEventKind::Drag(MouseButton::Left),
1427 &KeyModifiers {
1428 control: true,
1429 ..KeyModifiers::NONE
1430 },
1431 &MouseEventKind::Drag(MouseButton::Left),
1432 &KeyModifiers::NONE,
1433 ));
1434 }
1435
1436 #[test]
1437 fn reject_discrete_events() {
1438 assert!(!is_coalescable_mouse(
1439 &MouseEventKind::Press(MouseButton::Left),
1440 &KeyModifiers::NONE,
1441 &MouseEventKind::Press(MouseButton::Left),
1442 &KeyModifiers::NONE,
1443 ));
1444 assert!(!is_coalescable_mouse(
1445 &MouseEventKind::Release(MouseButton::Left),
1446 &KeyModifiers::NONE,
1447 &MouseEventKind::Moved,
1448 &KeyModifiers::NONE,
1449 ));
1450 assert!(!is_coalescable_mouse(
1451 &MouseEventKind::Moved,
1452 &KeyModifiers::NONE,
1453 &MouseEventKind::ScrollDown,
1454 &KeyModifiers::NONE,
1455 ));
1456 assert!(!is_coalescable_mouse(
1457 &MouseEventKind::ScrollUp,
1458 &KeyModifiers::NONE,
1459 &MouseEventKind::ScrollUp,
1460 &KeyModifiers::NONE,
1461 ));
1462 }
1463
1464 fn coalesce_through(
1469 events: &[Event],
1470 kind: MouseEventKind,
1471 modifiers: KeyModifiers,
1472 ) -> Option<Event> {
1473 let (tx, rx) = crossbeam_channel::bounded::<Event>(events.len());
1474 for e in events.iter().cloned() {
1475 tx.send(e).ok();
1476 }
1477 drop(tx);
1478
1479 let mut result = Event::Mouse(MouseEvent {
1480 kind,
1481 modifiers,
1482 column: 0,
1483 row: 0,
1484 });
1485
1486 if let Event::Mouse(ref mut mouse) = result
1487 && matches!(mouse.kind, MouseEventKind::Moved | MouseEventKind::Drag(_))
1488 {
1489 while let Ok(next) = rx.try_recv() {
1490 match next {
1491 Event::Mouse(ref next_mouse)
1492 if is_coalescable_mouse(
1493 &mouse.kind,
1494 &mouse.modifiers,
1495 &next_mouse.kind,
1496 &next_mouse.modifiers,
1497 ) =>
1498 {
1499 *mouse = *next_mouse;
1500 }
1501 _other => return Some(result),
1502 }
1503 }
1504 }
1505
1506 Some(result)
1507 }
1508
1509 #[test]
1510 fn coalesce_keeps_latest_moved_position() {
1511 let events = vec![
1512 Event::Mouse(MouseEvent {
1513 kind: MouseEventKind::Moved,
1514 modifiers: KeyModifiers::NONE,
1515 column: 5,
1516 row: 5,
1517 }),
1518 Event::Mouse(MouseEvent {
1519 kind: MouseEventKind::Moved,
1520 modifiers: KeyModifiers::NONE,
1521 column: 10,
1522 row: 10,
1523 }),
1524 Event::Mouse(MouseEvent {
1525 kind: MouseEventKind::Moved,
1526 modifiers: KeyModifiers::NONE,
1527 column: 15,
1528 row: 15,
1529 }),
1530 ];
1531 let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1532 let Event::Mouse(m) = result.unwrap() else {
1533 panic!("expected mouse")
1534 };
1535 assert_eq!((m.column, m.row), (15, 15));
1536 }
1537
1538 #[test]
1539 fn coalesce_keeps_latest_drag_position() {
1540 let events = vec![
1541 Event::Mouse(MouseEvent {
1542 kind: MouseEventKind::Drag(MouseButton::Left),
1543 modifiers: KeyModifiers::NONE,
1544 column: 1,
1545 row: 1,
1546 }),
1547 Event::Mouse(MouseEvent {
1548 kind: MouseEventKind::Drag(MouseButton::Left),
1549 modifiers: KeyModifiers::NONE,
1550 column: 2,
1551 row: 2,
1552 }),
1553 ];
1554 let result = coalesce_through(
1555 &events,
1556 MouseEventKind::Drag(MouseButton::Left),
1557 KeyModifiers::NONE,
1558 );
1559 let Event::Mouse(m) = result.unwrap() else {
1560 panic!("expected mouse")
1561 };
1562 assert_eq!((m.column, m.row), (2, 2));
1563 }
1564
1565 #[test]
1566 fn coalesce_stops_at_modifier_change() {
1567 let events = vec![Event::Mouse(MouseEvent {
1568 kind: MouseEventKind::Moved,
1569 modifiers: KeyModifiers {
1570 shift: true,
1571 ..KeyModifiers::NONE
1572 },
1573 column: 99,
1574 row: 99,
1575 })];
1576 let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1577 let Event::Mouse(m) = result.unwrap() else {
1578 panic!("expected mouse")
1579 };
1580 assert_eq!((m.column, m.row), (0, 0));
1583 }
1584
1585 #[test]
1586 fn coalesce_stops_at_non_mouse_event() {
1587 let key = Event::Key(KeyEvent {
1588 code: KeyCode::Char('q'),
1589 kind: KeyKind::Press,
1590 modifiers: KeyModifiers::NONE,
1591 });
1592 let events = vec![key.clone()];
1593 let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1594 let Event::Mouse(m) = result.unwrap() else {
1595 panic!("expected mouse")
1596 };
1597 assert_eq!((m.column, m.row), (0, 0));
1599 }
1600
1601 #[test]
1602 fn coalesce_stops_at_discrete_mouse_event() {
1603 let events = vec![Event::Mouse(MouseEvent {
1604 kind: MouseEventKind::Press(MouseButton::Left),
1605 modifiers: KeyModifiers::NONE,
1606 column: 10,
1607 row: 10,
1608 })];
1609 let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1610 let Event::Mouse(m) = result.unwrap() else {
1611 panic!("expected mouse")
1612 };
1613 assert_eq!((m.column, m.row), (0, 0));
1615 }
1616}
1617
1618#[allow(clippy::unwrap_used)]
1622#[cfg(test)]
1623#[allow(clippy::type_complexity)]
1624mod snapshot_tests {
1625 use super::*;
1626
1627 fn render_and_capture(pty_bytes: &[u8], rows: u16, cols: u16, clear_display: bool) -> Vec<u8> {
1630 let rt = tokio::runtime::Builder::new_current_thread()
1631 .build()
1632 .expect("tokio rt");
1633 let (push_tx, push_rx) = crossbeam_channel::bounded(16);
1634 let input_writer: Box<dyn FnMut(&[u8]) -> io::Result<()> + Send> = Box::new(|_| Ok(()));
1635 let mut pane = RemotePane::new(
1636 0,
1637 None,
1638 rt.handle().clone(),
1639 cols,
1640 rows,
1641 push_rx,
1642 input_writer,
1643 );
1644 drop(rt); push_tx.send(pty_bytes.to_vec()).ok();
1647 pane.drain_pushes();
1648
1649 let parser = pane.shared_parser();
1650 let parser = parser.lock().unwrap();
1651 let screen = parser.screen();
1652 let (rows, cols) = screen.size();
1653 let mut out = Vec::new();
1654 render_frame(&mut out, screen, rows, cols, clear_display).unwrap();
1655 out
1656 }
1657
1658 fn escape_ansi(bytes: &[u8]) -> String {
1660 let mut out: Vec<u8> = Vec::with_capacity(bytes.len() * 4);
1661 for &b in bytes {
1662 match b {
1663 b'\x1b' => out.extend_from_slice(b"\\x1b"),
1664 b'\n' => out.extend_from_slice(b"\\n"),
1665 b'\r' => out.extend_from_slice(b"\\r"),
1666 b'\t' => out.extend_from_slice(b"\\t"),
1667 0x20..=0x7e => out.push(b),
1668 _ => {
1669 out.push(b'\\');
1670 out.push(b'x');
1671 out.extend_from_slice(&hex_byte(b));
1672 }
1673 }
1674 }
1675 unsafe { String::from_utf8_unchecked(out) }
1677 }
1678
1679 fn hex_byte(b: u8) -> [u8; 2] {
1680 #[inline]
1681 fn hex_nibble(n: u8) -> u8 {
1682 let digit = n & 0x0f;
1683 if digit < 10 {
1684 b'0' + digit
1685 } else {
1686 b'a' + digit - 10
1687 }
1688 }
1689 [hex_nibble(b >> 4), hex_nibble(b)]
1690 }
1691
1692 #[test]
1695 fn snapshot_empty_grid() {
1696 let out = render_and_capture(b"", 4, 8, false);
1697 insta::assert_snapshot!("empty_grid", escape_ansi(&out));
1698 }
1699
1700 #[test]
1701 fn snapshot_basic_text() {
1702 let out = render_and_capture(b"Hello\nWorld", 4, 8, false);
1703 insta::assert_snapshot!("basic_text", escape_ansi(&out));
1704 }
1705
1706 #[test]
1707 fn snapshot_colored_text() {
1708 let out = render_and_capture(b"\x1b[31mred\x1b[1mbold", 4, 8, false);
1709 insta::assert_snapshot!("colored_text", escape_ansi(&out));
1710 }
1711
1712 #[test]
1713 fn snapshot_normal_char_at_margin() {
1714 let out = render_and_capture(b"ABCD", 1, 4, false);
1717 insta::assert_snapshot!("normal_char_at_margin", escape_ansi(&out));
1718 }
1719
1720 #[test]
1721 fn snapshot_clear_display() {
1722 let out = render_and_capture(b"", 4, 8, true);
1723 insta::assert_snapshot!("clear_display", escape_ansi(&out));
1724 }
1725
1726 #[test]
1727 fn snapshot_hidden_cursor() {
1728 let out = render_and_capture(b"\x1b[?25l", 4, 8, false);
1729 insta::assert_snapshot!("hidden_cursor", escape_ansi(&out));
1730 }
1731
1732 #[test]
1733 fn snapshot_color_across_margin() {
1734 let out = render_and_capture(b"\x1b[41mX", 1, 4, false);
1737 insta::assert_snapshot!("color_across_margin", escape_ansi(&out));
1738 }
1739
1740 #[test]
1741 fn snapshot_multi_row_fill() {
1742 let out = render_and_capture(b"ABCDEFGHIJKL", 3, 4, false);
1745 insta::assert_snapshot!("multi_row_fill", escape_ansi(&out));
1746 }
1747
1748 #[test]
1749 fn snapshot_wide_char_margin() {
1750 let out = render_and_capture(
1753 b"B\xe3\x81\x82", 1,
1755 3,
1756 false,
1757 );
1758 insta::assert_snapshot!("wide_char_margin", escape_ansi(&out));
1759 }
1760
1761 #[test]
1762 fn snapshot_wide_char_middle() {
1763 let out = render_and_capture(
1767 b"A\xe3\x81\x82\xe3\x81\x83", 1,
1769 5,
1770 false,
1771 );
1772 insta::assert_snapshot!("wide_char_middle", escape_ansi(&out));
1773 }
1774}