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
105const NESTED_SESSION_FATAL: &str = "\
110FATAL: Attempted to run term-session inside an existing term-session environment.
111Session inception can cause terminal buffer corruption and accidental gateway stops.
112
113To force nested execution, rerun with:
114 term-session --allow-nested [args...]";
115
116fn should_block_nesting(active_in_session: bool, allow_nested: bool) -> bool {
119 active_in_session && !allow_nested
120}
121
122pub fn init_terminal<W: Write>(mut writer: W) -> io::Result<TerminalGuard<W>> {
130 if std::io::stdin().is_terminal() {
131 enable_raw_mode()?;
132 }
133 writer.queue(EnterAlternateScreen)?;
134 writer.queue(Hide)?;
135 writer.queue(EnableBracketedPaste)?;
136 writer.queue(crossterm::event::EnableMouseCapture)?;
137 #[cfg(windows)]
145 term_wm_crossterm_adapter::set_mouse_capture_with(&mut writer, true)?;
146 writer.flush()?;
147 Ok(TerminalGuard {
148 writer: Some(writer),
149 })
150}
151
152pub struct TerminalGuard<W: Write = std::io::Stdout> {
156 writer: Option<W>,
157}
158
159impl<W: Write> Drop for TerminalGuard<W> {
160 fn drop(&mut self) {
161 if let Some(ref mut writer) = self.writer {
162 let _ = writer.queue(crossterm::event::DisableMouseCapture);
163 #[cfg(windows)]
166 let _ = term_wm_crossterm_adapter::set_mouse_capture_with(writer, false);
167 let _ = writer.queue(DisableBracketedPaste);
168 let _ = writer.queue(Show);
169 let _ = writer.queue(LeaveAlternateScreen);
170 if std::io::stdin().is_terminal() {
171 let _ = disable_raw_mode();
172 }
173 let _ = writer.flush();
174 }
175 }
176}
177
178fn convert_crossterm_event(evt: crossterm::event::Event) -> Option<Event> {
180 term_wm_crossterm_adapter::try_translate_event(evt)
181}
182
183fn attributed_mouse_is_motion(e: &Event) -> bool {
188 matches!(
189 e,
190 Event::Mouse(m) if matches!(m.kind, MouseEventKind::Moved | MouseEventKind::Drag(_))
191 )
192}
193
194fn attributed_mouse_coalescable(a: &Event, b: &Event) -> bool {
197 match (a, b) {
198 (Event::Mouse(am), Event::Mouse(bm)) => {
199 is_coalescable_mouse(&am.kind, &am.modifiers, &bm.kind, &bm.modifiers)
200 }
201 _ => false,
202 }
203}
204
205fn is_coalescable_mouse(
206 a_kind: &MouseEventKind,
207 a_mod: &KeyModifiers,
208 b_kind: &MouseEventKind,
209 b_mod: &KeyModifiers,
210) -> bool {
211 if a_mod != b_mod {
212 return false;
213 }
214 match (a_kind, b_kind) {
215 (MouseEventKind::Moved, MouseEventKind::Moved) => true,
216 (MouseEventKind::Drag(btn1), MouseEventKind::Drag(btn2)) => btn1 == btn2,
217 _ => false,
218 }
219}
220
221fn client_user() -> String {
226 #[cfg(unix)]
227 {
228 unsafe {
229 let pw = libc::getpwuid(libc::getuid());
230 if !pw.is_null() {
231 let name = std::ffi::CStr::from_ptr((*pw).pw_name);
232 if let Ok(s) = name.to_str()
233 && !s.is_empty()
234 {
235 return s.to_string();
236 }
237 }
238 }
239 std::env::var("USER").unwrap_or_default()
240 }
241 #[cfg(windows)]
242 {
243 if let Ok(u) = std::env::var("USERNAME")
244 && !u.is_empty()
245 {
246 return u;
247 }
248 windows_username().unwrap_or_default()
249 }
250 #[cfg(not(any(unix, windows)))]
251 {
252 String::new()
253 }
254}
255
256#[cfg(windows)]
259fn windows_username() -> Option<String> {
260 use std::os::windows::ffi::OsStringExt;
261 use windows_sys::Win32::System::WindowsProgramming::GetUserNameW;
262 let mut buf = [0u16; 256];
263 let mut len = buf.len() as u32;
264 let ok = unsafe { GetUserNameW(buf.as_mut_ptr(), &mut len) };
265 if ok == 0 {
266 return None;
267 }
268 let s = std::ffi::OsString::from_wide(&buf[..len as usize])
269 .to_string_lossy()
270 .into_owned();
271 if s.is_empty() { None } else { Some(s) }
272}
273
274fn client_version() -> String {
277 env!("CARGO_PKG_VERSION").to_string()
278}
279
280fn client_ssh_ip() -> Option<String> {
284 for var in ["SSH_CLIENT", "SSH_CONNECTION"] {
285 if let Ok(v) = std::env::var(var) {
286 let ip = v.split_whitespace().next()?;
287 if !ip.is_empty() {
288 return Some(ip.to_string());
289 }
290 }
291 }
292 None
293}
294
295pub fn run_session(
306 socket_path: &str,
307 channel: &str,
308 cmd: &[String],
309 allow_nested: bool,
310) -> io::Result<Option<String>> {
311 if should_block_nesting(
316 std::env::var_os(term_session_muxio_service_definitions::SESSION_ACTIVE_ENV_VAR).is_some(),
317 allow_nested,
318 ) {
319 return Err(io::Error::other(NESTED_SESSION_FATAL));
320 }
321
322 #[cfg(windows)]
327 disable_quick_edit();
328
329 let rt =
330 tokio::runtime::Runtime::new().map_err(|e| io::Error::other(format!("runtime: {e}")))?;
331
332 let client: Arc<RpcIpcClient> = rt
334 .block_on(RpcIpcClient::new(socket_path))
335 .map_err(|e| io::Error::new(io::ErrorKind::ConnectionRefused, format!("{e:?}")))?;
336
337 let abi_fault = |e: &dyn std::fmt::Display| -> io::Error {
341 io::Error::other(format!(
342 "FATAL: Protocol ABI mismatch. A legacy daemon may be occupying the IPC socket. Manually terminate the daemon process before continuing. (cause: {e})"
343 ))
344 };
345
346 let server_cols = Arc::new(AtomicU16::new(0));
350 let server_rows = Arc::new(AtomicU16::new(0));
351 let resize_pending = Arc::new(AtomicBool::new(false));
352
353 {
354 let cols_ref = Arc::clone(&server_cols);
355 let rows_ref = Arc::clone(&server_rows);
356 let pending_ref = Arc::clone(&resize_pending);
357 rt.block_on(client.get_endpoint().register_prebuffered(
358 OnPtyResized::METHOD_ID,
359 move |payload, _ctx| {
360 let cols_ref = Arc::clone(&cols_ref);
361 let rows_ref = Arc::clone(&rows_ref);
362 let pending_ref = Arc::clone(&pending_ref);
363 async move {
364 let (cols, rows) = OnPtyResized::decode_request(&payload)
365 .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
366 cols_ref.store(cols, Ordering::Relaxed);
367 rows_ref.store(rows, Ordering::Relaxed);
368 pending_ref.store(true, Ordering::Relaxed);
369 OnPtyResized::encode_response(())
370 .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
371 }
372 },
373 ))
374 .map_err(|e| io::Error::other(format!("register OnPtyResized: {e:?}")))?;
375 }
376
377 let rebind_target: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
380
381 {
382 let target_ref = Arc::clone(&rebind_target);
383 rt.block_on(client.get_endpoint().register_prebuffered(
384 OnWorkspaceRebind::METHOD_ID,
385 move |payload, _ctx| {
386 let target_ref = Arc::clone(&target_ref);
387 async move {
388 let req = OnWorkspaceRebind::decode_request(&payload)
389 .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
390 *target_ref.lock().unwrap_or_else(|err| err.into_inner()) = Some(req.target);
391 OnWorkspaceRebind::encode_response(())
392 .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
393 }
394 },
395 ))
396 .map_err(|e| io::Error::other(format!("register OnWorkspaceRebind: {e:?}")))?;
397 }
398
399 let (push_tx, push_rx) = crossbeam_channel::bounded::<Vec<u8>>(PTY_OUTPUT_CHANNEL_CAPACITY);
403 let (clip_tx, clip_rx) = crossbeam_channel::bounded::<String>(CLIPBOARD_CHANNEL_CAPACITY);
404
405 let (term_cols, term_rows) = match crossterm::terminal::size() {
412 Ok((c, r)) => (c.max(MIN_TERM_COLS), r.max(MIN_TERM_ROWS)),
413 Err(_) => (FALLBACK_TERM_COLS, FALLBACK_TERM_ROWS),
414 };
415 let hostname = hostname::get()
416 .map(|h| h.to_string_lossy().into_owned())
417 .unwrap_or_else(|_| "unknown".to_string());
418
419 let (actual_cols, actual_rows) = rt.block_on(async {
420 let conn_id = Attach::call(
423 &*client,
424 AttachRequest {
425 channel: channel.to_string(),
426 hostname,
427 pid: std::process::id() as u64,
428 user: client_user(),
429 version: client_version(),
430 ssh_ip: client_ssh_ip(),
431 },
432 )
433 .await
434 .map_err(|e| abi_fault(&e))?;
435 let cmd = if cmd.is_empty() {
437 None
438 } else {
439 Some(cmd.to_vec())
440 };
441 let launch_cwd = std::env::current_dir().ok().map(path_wire::encode_path);
447 let SpawnResponse {
448 id: _session_id,
449 cols: actual_cols,
450 rows: actual_rows,
451 ..
452 } = Spawn::call(
453 &*client,
454 SpawnRequest {
455 cmd,
456 cols: term_cols,
457 rows: term_rows,
458 cwd: launch_cwd,
459 },
460 )
461 .await
462 .map_err(|e| abi_fault(&e))?;
463 let _ = conn_id;
464 Ok::<(u16, u16), io::Error>((actual_cols, actual_rows))
465 })?;
466
467 let writer = rt.block_on(async {
469 let (_, mut reader) = client
472 .open_channel(SUBSCRIBE_OUTPUT_METHOD_ID, 0)
473 .await
474 .map_err(|e| io::Error::other(format!("subscribe: {e:?}")))?;
475
476 rt.spawn(async move {
480 let mut osc52 = Osc52Extractor::new();
481 let mut prev_tail: [u8; PREV_TAIL_LEN] = [0; PREV_TAIL_LEN];
482
483 while let Some(chunk) = reader.recv().await {
484 if let Ok(mut data) = chunk {
485 if let Some(text) = osc52.push(&data, &prev_tail) {
486 let _ = clip_tx.try_send(text);
487 }
488
489 let n = data.len();
490 if n >= PREV_TAIL_LEN {
491 prev_tail.copy_from_slice(&data[n - PREV_TAIL_LEN..n]);
492 } else if n > 0 {
493 prev_tail.rotate_left(n);
494 prev_tail[PREV_TAIL_LEN - n..].copy_from_slice(&data[..n]);
495 }
496
497 while let Err(crossbeam_channel::TrySendError::Full(pending)) =
500 push_tx.try_send(data)
501 {
502 data = pending;
503 tokio::time::sleep(Duration::from_millis(BACKPRESSURE_SLEEP_MS)).await;
504 }
505 } else {
506 break;
507 }
508 }
509 if let Some(text) = osc52.finish() {
512 let _ = clip_tx.try_send(text);
513 }
514 });
515
516 let (writer, _) = client
519 .open_channel(STREAM_INPUT_METHOD_ID, 0)
520 .await
521 .map_err(|e| io::Error::other(format!("stream input: {e:?}")))?;
522
523 Ok::<_, io::Error>(writer)
524 })?;
525
526 let input_writer = Box::new(move |data: &[u8]| -> io::Result<()> {
527 writer
528 .send(data.to_vec())
529 .map_err(|e| io::Error::other(e.to_string()))?;
530 Ok(())
531 });
532
533 let mut pane = RemotePane::new(
534 1u64,
535 Some(client.clone()),
536 rt.handle().clone(),
537 term_cols,
538 term_rows,
539 push_rx.clone(),
540 input_writer,
541 );
542
543 let (attributed_tx, mut attributed_rx) = tokio::sync::mpsc::channel::<
546 term_session_muxio_service_definitions::SendAttributedInputRequest,
547 >(1024);
548 {
549 let client_clone = client.clone();
550 rt.spawn(async move {
551 use muxio_rpc_service_caller::prebuffered::RpcCallPrebuffered;
552 use term_session_muxio_service_definitions::SendAttributedInput;
553
554 let mut pending_req = None;
555 loop {
556 let mut req = match pending_req.take() {
557 Some(r) => r,
558 None => match attributed_rx.recv().await {
559 Some(r) => r,
560 None => break,
561 },
562 };
563
564 while attributed_mouse_is_motion(&req.event) {
569 match attributed_rx.try_recv() {
570 Ok(next_req)
571 if attributed_mouse_coalescable(&req.event, &next_req.event) =>
572 {
573 req = next_req;
574 }
575 Ok(next_req) => {
576 pending_req = Some(next_req);
577 break;
578 }
579 Err(_) => break,
580 }
581 }
582 let _ = SendAttributedInput::call(&*client_clone, req).await;
583 }
584 });
585 }
586
587 for _ in 0..INITIAL_WAIT_ITERS {
589 pane.drain_pushes();
590 let parser = pane.shared_parser();
591 let parser = parser.lock().unwrap_or_else(|e| e.into_inner());
592 if !parser.screen().contents_formatted().is_empty() {
593 break;
594 }
595 drop(parser);
596 std::thread::sleep(Duration::from_millis(INITIAL_WAIT_SLEEP_MS));
597 }
598
599 {
601 let parser = pane.shared_parser();
602 let mut parser_lk = parser.lock().unwrap_or_else(|e| e.into_inner());
603 let (cur_rows, cur_cols) = parser_lk.screen().size();
604 if actual_cols != cur_cols || actual_rows != cur_rows {
605 parser_lk.screen_mut().set_size(actual_rows, actual_cols);
606 }
607 drop(parser_lk);
608 }
609
610 #[cfg(unix)]
621 let _ = redirect_fd_to_tracing(libc::STDERR_FILENO, true);
622 let _guard = init_terminal(stdout())?;
623 let mut out = stdout();
624
625 let mut clipboard = Clipboard::new();
626 let sigint = install_sigint_handler()?;
627
628 let (input_tx, input_rx) = crossbeam_channel::bounded::<Event>(INPUT_CHANNEL_CAPACITY);
630
631 std::thread::Builder::new()
635 .name("crossterm-input".into())
636 .spawn(move || {
637 loop {
638 match crossterm::event::poll(Duration::from_millis(INPUT_POLL_MS)) {
639 Ok(true) => {
640 if let Ok(crossterm_evt) = crossterm::event::read()
641 && let Some(e) = convert_crossterm_event(crossterm_evt)
642 && input_tx.send(e).is_err()
643 {
644 break;
645 }
646 }
647 Ok(false) => continue,
648 Err(_) => break,
649 }
650 }
651 })
652 .map_err(|e| io::Error::other(format!("spawn input thread: {e}")))?;
653
654 {
656 let parser = pane.shared_parser();
657 let parser = parser.lock().unwrap_or_else(|e| e.into_inner());
658 let screen = parser.screen();
659 let (rows, cols) = screen.size();
660 render_frame(&mut out, screen, rows, cols, false)?;
661 }
662
663 let mut pending_input: Option<Event> = None;
664 loop {
665 let mut force_render = false;
666 let mut clear_display = false;
667
668 let apply_pending_resize = |shared_parser: &Arc<Mutex<Parser>>| -> bool {
671 if resize_pending.swap(false, Ordering::Relaxed) {
672 let cols = server_cols.load(Ordering::Relaxed);
673 let rows = server_rows.load(Ordering::Relaxed);
674 if cols > 0 && rows > 0 {
675 let mut parser_lk = shared_parser.lock().unwrap_or_else(|e| e.into_inner());
676 let (cur_rows, cur_cols) = parser_lk.screen().size();
677 if cur_cols != cols || cur_rows != rows {
678 parser_lk.screen_mut().set_size(rows, cols);
679 return true;
680 }
681 }
682 }
683 false
684 };
685
686 let resized = apply_pending_resize(&pane.shared_parser());
688 force_render |= resized;
689 clear_display |= resized;
690
691 if let Some(target) = rebind_target
695 .lock()
696 .unwrap_or_else(|err| err.into_inner())
697 .take()
698 {
699 return Ok(Some(target));
700 }
701
702 let input_event = if let Some(evt) = pending_input.take() {
705 Some(evt)
706 } else {
707 crossbeam_channel::select! {
708 recv(input_rx) -> msg => {
709 match msg {
710 Ok(evt) => Some(evt),
711 Err(_) => return Err(io::Error::other("input thread died")),
712 }
713 }
714 recv(push_rx) -> msg => {
715 match msg {
716 Ok(data) => {
717 let resized = apply_pending_resize(&pane.shared_parser());
722 force_render |= resized;
723 clear_display |= resized;
724
725 let parser = pane.shared_parser();
727 let mut parser = parser.lock().unwrap_or_else(|e| e.into_inner());
728 parser.process(&data);
729 None
730 }
731 Err(_) => {
732 None
735 }
736 }
737 }
738 recv(crossbeam_channel::after(Duration::from_millis(100))) -> _ => {
739 force_render = true;
742 None
743 }
744 }
745 };
746
747 let has_new_data = pane.drain_pushes() || input_event.is_none();
749
750 while let Ok(text) = clip_rx.try_recv() {
752 clipboard.set(&text);
753 }
754
755 if sigint.received() {
757 sigint.ack();
758 let _ = pane.write_bytes(&[0x03]);
759 }
760
761 if let Some(mut evt) = input_event {
763 if let Event::Mouse(ref mut mouse) = evt
768 && matches!(mouse.kind, MouseEventKind::Moved | MouseEventKind::Drag(_))
769 {
770 while let Ok(next_evt) = input_rx.try_recv() {
771 match next_evt {
772 Event::Mouse(ref next_mouse)
773 if is_coalescable_mouse(
774 &mouse.kind,
775 &mouse.modifiers,
776 &next_mouse.kind,
777 &next_mouse.modifiers,
778 ) =>
779 {
780 *mouse = *next_mouse;
781 }
782 other => {
783 pending_input = Some(other);
784 break;
785 }
786 }
787 }
788 }
789
790 {
794 use term_session_muxio_service_definitions::SendAttributedInputRequest;
795 let _ = attributed_tx.try_send(SendAttributedInputRequest {
796 channel: channel.to_string(),
797 event: evt.clone(),
798 });
799 }
800
801 match evt {
802 Event::Key(ref key)
803 if key.kind == KeyKind::Press || key.kind == KeyKind::Repeat =>
804 {
805 let bytes = key_to_bytes(key, false);
806 if !bytes.is_empty() {
807 let _ = pane.write_bytes(&bytes);
808 }
809 }
810 Event::Mouse(ref mouse) => {
811 let mouse_active = {
812 let parser = pane.shared_parser();
813 let parser = parser.lock().unwrap_or_else(|e| e.into_inner());
814 parser.screen().mouse_protocol_mode() != MouseProtocolMode::None
815 };
816 if mouse_active {
817 let bytes = mouse_event_to_bytes(mouse, MouseProtocolEncoding::Sgr);
818 if !bytes.is_empty() {
819 let _ = pane.write_bytes(&bytes);
820 }
821 }
822 }
823 Event::Resize(w, h) => {
824 let size = PtySize {
825 rows: h,
826 cols: w,
827 pixel_width: 0,
828 pixel_height: 0,
829 };
830 if let Err(err) = pane.resize(size) {
831 tracing::warn!(error = %err, "resize request failed on PTY pane");
832 }
833 force_render = true;
834 clear_display = true;
835 }
836 Event::Paste(text) => {
837 let mut wrapped = Vec::with_capacity(text.len() + BRACKETED_PASTE_OVERHEAD);
838 wrapped.extend_from_slice(b"\x1b[200~");
839 wrapped.extend_from_slice(text.as_bytes());
840 wrapped.extend_from_slice(b"\x1b[201~");
841 let _ = pane.write_bytes(&wrapped);
842 }
843 _ => {}
844 }
845 }
846
847 if !client.is_connected() {
849 return Err(io::Error::other("connection to session server lost"));
850 }
851
852 if has_new_data || force_render {
854 let parser = pane.shared_parser();
855 let parser = parser.lock().unwrap_or_else(|e| e.into_inner());
856 let screen = parser.screen();
857 let (rows, cols) = screen.size();
858 render_frame(&mut out, screen, rows, cols, clear_display)?;
859 }
860
861 if pane.has_exited() {
863 return Ok(None);
864 }
865 }
866}
867
868#[derive(Default, PartialEq, Clone, Copy)]
869struct CellStyle {
870 fg: term_wm_vt100::Color,
871 bg: term_wm_vt100::Color,
872 bold: bool,
873 dim: bool,
874 italic: bool,
875 underline: bool,
876 inverse: bool,
877}
878
879impl CellStyle {
880 fn from_cell(cell: &term_wm_vt100::Cell) -> Self {
881 Self {
882 fg: cell.fgcolor(),
883 bg: cell.bgcolor(),
884 bold: cell.bold(),
885 dim: cell.dim(),
886 italic: cell.italic(),
887 underline: cell.underline(),
888 inverse: cell.inverse(),
889 }
890 }
891}
892
893fn apply_sgr(out: &mut dyn Write, style: &CellStyle) -> io::Result<()> {
894 write!(out, "\x1b[0m")?;
895 if style.bold {
896 write!(out, "\x1b[1m")?;
897 }
898 if style.dim {
899 write!(out, "\x1b[2m")?;
900 }
901 if style.italic {
902 write!(out, "\x1b[3m")?;
903 }
904 if style.underline {
905 write!(out, "\x1b[4m")?;
906 }
907 if style.inverse {
908 write!(out, "\x1b[7m")?;
909 }
910 match style.fg {
911 term_wm_vt100::Color::Idx(i) => write!(out, "\x1b[38;5;{}m", i)?,
912 term_wm_vt100::Color::Rgb(r, g, b) => write!(out, "\x1b[38;2;{};{};{}m", r, g, b)?,
913 _ => {}
914 }
915 match style.bg {
916 term_wm_vt100::Color::Idx(i) => write!(out, "\x1b[48;5;{}m", i)?,
917 term_wm_vt100::Color::Rgb(r, g, b) => write!(out, "\x1b[48;2;{};{};{}m", r, g, b)?,
918 _ => {}
919 }
920 Ok(())
921}
922
923pub fn render_frame(
924 out: &mut dyn Write,
925 screen: &Screen,
926 rows: u16,
927 cols: u16,
928 clear_display: bool,
929) -> io::Result<()> {
930 let mut buf =
931 Vec::with_capacity((rows as usize) * (cols as usize) * RENDER_BUF_CELL_MULTIPLIER);
932 let mut active_style = CellStyle::default();
933
934 buf.extend_from_slice(b"\x1b[?2026h\x1b[?25l\x1b[0m");
936 if clear_display {
937 buf.extend_from_slice(b"\x1b[2J");
938 }
939 buf.extend_from_slice(b"\x1b[?7l");
940
941 for row in 0..rows {
942 write!(buf, "\x1b[{};1H", row + 1)?;
943
944 let mut col: u16 = 0;
945 while col < cols {
946 let cell_opt = screen.cell(row, col);
950 let contents = cell_opt.map_or("", |c| c.contents());
951 let width = if contents.is_empty() {
952 1
953 } else {
954 unicode_width::UnicodeWidthStr::width(contents).max(1) as u16
955 };
956
957 if col + width >= cols {
961 buf.extend_from_slice(b"\x1b[0m\x1b[K");
962 active_style = CellStyle::default();
963 }
964
965 let style = cell_opt.map(CellStyle::from_cell).unwrap_or_default();
966 if style != active_style {
967 apply_sgr(&mut buf, &style)?;
968 active_style = style;
969 }
970
971 if contents.is_empty() {
972 buf.push(b' ');
973 } else {
974 buf.extend_from_slice(contents.as_bytes());
975 }
976
977 col += width;
978 }
979 }
980
981 buf.extend_from_slice(b"\x1b[?7h");
982 buf.extend_from_slice(b"\x1b[0m");
983 let (cur_row, cur_col) = screen.cursor_position();
984 write!(buf, "\x1b[{};{}H", cur_row + 1, cur_col + 1)?;
985 if screen.hide_cursor() {
986 buf.extend_from_slice(b"\x1b[?25l");
987 } else {
988 buf.extend_from_slice(b"\x1b[?25h");
989 }
990 buf.extend_from_slice(b"\x1b[?2026l");
992
993 out.write_all(&buf)?;
994 out.flush()
995}
996
997#[allow(clippy::unwrap_used)]
998#[cfg(test)]
999mod tests {
1000 use super::*;
1001 use std::sync::{Arc, Mutex};
1002
1003 use term_wm_events::{KeyCode, KeyEvent, MouseButton, MouseEvent};
1004
1005 #[test]
1006 fn should_block_nesting_truth_table() {
1007 assert!(
1008 should_block_nesting(true, false),
1009 "nested, no override -> block"
1010 );
1011 assert!(
1012 !should_block_nesting(true, true),
1013 "nested, allow -> proceed"
1014 );
1015 assert!(!should_block_nesting(false, false), "not nested -> proceed");
1016 assert!(
1017 !should_block_nesting(false, true),
1018 "not nested + allow -> proceed"
1019 );
1020 }
1021
1022 struct TestWriter {
1023 buf: Arc<Mutex<Vec<u8>>>,
1024 }
1025
1026 impl TestWriter {
1027 fn new() -> (Self, Arc<Mutex<Vec<u8>>>) {
1028 let buf = Arc::new(Mutex::new(Vec::new()));
1029 (Self { buf: buf.clone() }, buf)
1030 }
1031 }
1032
1033 impl Write for TestWriter {
1034 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
1035 self.buf.lock().unwrap().extend_from_slice(buf);
1036 Ok(buf.len())
1037 }
1038 fn flush(&mut self) -> io::Result<()> {
1039 Ok(())
1040 }
1041 }
1042
1043 #[test]
1048 fn init_terminal_writes_bracketed_paste_enable() {
1049 let (writer, buf) = TestWriter::new();
1050 let _guard = init_terminal(writer).expect("init_terminal");
1051 let bytes = buf.lock().unwrap();
1052 assert!(
1053 bytes
1054 .windows(b"\x1b[?2004h".len())
1055 .any(|w| w == b"\x1b[?2004h")
1056 );
1057 }
1058
1059 #[test]
1064 fn init_terminal_writes_mouse_enable_ansi() {
1065 let (writer, buf) = TestWriter::new();
1066 let _guard = init_terminal(writer).expect("init_terminal");
1067 let bytes = buf.lock().unwrap();
1068 assert!(
1069 bytes
1070 .windows(b"\x1b[?1000h".len())
1071 .any(|w| w == b"\x1b[?1000h"),
1072 "init_terminal must emit the mouse-enable ANSI so a host routes mouse input"
1073 );
1074 }
1075
1076 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
1082 static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1083 LOCK.lock().unwrap_or_else(|e| e.into_inner())
1084 }
1085
1086 #[test]
1087 fn client_ssh_ip_from_ssh_client() {
1088 let _guard = env_lock();
1089 unsafe {
1090 std::env::set_var("SSH_CLIENT", "192.168.1.50 54321 22");
1091 std::env::remove_var("SSH_CONNECTION");
1092 }
1093 assert_eq!(client_ssh_ip().as_deref(), Some("192.168.1.50"));
1094 unsafe {
1095 std::env::remove_var("SSH_CLIENT");
1096 }
1097 }
1098
1099 #[test]
1100 fn client_ssh_ip_from_ssh_connection_fallback() {
1101 let _guard = env_lock();
1102 unsafe {
1103 std::env::remove_var("SSH_CLIENT");
1104 std::env::set_var("SSH_CONNECTION", "10.0.0.7 48000 10.0.0.1 22");
1105 }
1106 assert_eq!(client_ssh_ip().as_deref(), Some("10.0.0.7"));
1107 unsafe {
1108 std::env::remove_var("SSH_CONNECTION");
1109 }
1110 }
1111
1112 #[test]
1113 fn client_ssh_ip_ssh_client_wins_over_connection() {
1114 let _guard = env_lock();
1115 unsafe {
1116 std::env::set_var("SSH_CLIENT", "1.2.3.4 1000 22");
1117 std::env::set_var("SSH_CONNECTION", "9.9.9.9 2000 1.1.1.1 22");
1118 }
1119 assert_eq!(client_ssh_ip().as_deref(), Some("1.2.3.4"));
1120 unsafe {
1121 std::env::remove_var("SSH_CLIENT");
1122 std::env::remove_var("SSH_CONNECTION");
1123 }
1124 }
1125
1126 #[test]
1127 fn client_ssh_ip_none_when_local() {
1128 let _guard = env_lock();
1129 unsafe {
1130 std::env::remove_var("SSH_CLIENT");
1131 std::env::remove_var("SSH_CONNECTION");
1132 }
1133 assert_eq!(client_ssh_ip(), None);
1134 }
1135
1136 #[test]
1137 fn client_version_matches_package() {
1138 assert_eq!(client_version(), env!("CARGO_PKG_VERSION"));
1139 }
1140
1141 #[test]
1142 fn client_user_non_empty() {
1143 assert!(!client_user().is_empty(), "client user must resolve");
1144 }
1145
1146 #[test]
1147 #[cfg(windows)]
1148 fn client_user_prefers_username_env_when_set() {
1149 let _guard = env_lock();
1150 unsafe {
1151 std::env::set_var("USERNAME", "win-test-user");
1152 }
1153 assert_eq!(client_user(), "win-test-user");
1154 unsafe {
1155 std::env::remove_var("USERNAME");
1156 }
1157 }
1158
1159 #[test]
1160 #[cfg(windows)]
1161 fn client_user_falls_back_to_getusername_when_env_absent() {
1162 let _guard = env_lock();
1163 unsafe {
1164 std::env::remove_var("USERNAME");
1165 }
1166 assert!(
1169 !client_user().is_empty(),
1170 "GetUserNameW fallback must resolve a user"
1171 );
1172 }
1173
1174 #[test]
1177 fn terminal_guard_teardown_writes_bracketed_paste_disable() {
1178 let (writer, buf) = TestWriter::new();
1179 {
1180 let _guard = TerminalGuard {
1181 writer: Some(writer),
1182 };
1183 }
1184 let bytes = buf.lock().unwrap();
1185 assert!(
1186 bytes
1187 .windows(b"\x1b[?2004l".len())
1188 .any(|w| w == b"\x1b[?2004l")
1189 );
1190 }
1191
1192 #[test]
1195 fn terminal_guard_teardown_writes_mouse_disable_ansi() {
1196 let (writer, buf) = TestWriter::new();
1197 {
1198 let _guard = TerminalGuard {
1199 writer: Some(writer),
1200 };
1201 }
1202 let bytes = buf.lock().unwrap();
1203 assert!(
1204 bytes
1205 .windows(b"\x1b[?1000l".len())
1206 .any(|w| w == b"\x1b[?1000l"),
1207 "TerminalGuard teardown must emit the mouse-disable ANSI"
1208 );
1209 }
1210
1211 #[test]
1214 fn init_and_teardown_roundtrip_contains_both_sequences() {
1215 let (writer, buf) = TestWriter::new();
1216 let guard = init_terminal(writer).expect("init_terminal");
1217 drop(guard);
1218 let bytes = buf.lock().unwrap();
1219 assert!(
1220 bytes
1221 .windows(b"\x1b[?2004h".len())
1222 .any(|w| w == b"\x1b[?2004h")
1223 );
1224 assert!(
1225 bytes
1226 .windows(b"\x1b[?2004l".len())
1227 .any(|w| w == b"\x1b[?2004l")
1228 );
1229 }
1230
1231 #[test]
1234 fn test_prev_parser_resize_sync_matches_fresh_parser() {
1235 let mut prev_parser = term_wm_vt100::Parser::new(24, 80, 0);
1236 prev_parser.process(b"initial screen content");
1237
1238 let (new_rows, new_cols) = (40, 120);
1240 let new_formatted_content = {
1241 let mut p = term_wm_vt100::Parser::new(new_rows, new_cols, 0);
1242 p.process(b"resized screen content");
1243 p.screen().contents_formatted().to_vec()
1244 };
1245
1246 prev_parser.screen_mut().set_size(new_rows, new_cols);
1248 prev_parser.process(b"\x1bc");
1249 prev_parser.process(&new_formatted_content);
1250
1251 let mut fresh_parser = term_wm_vt100::Parser::new(new_rows, new_cols, 0);
1253 fresh_parser.process(&new_formatted_content);
1254
1255 assert_eq!(
1256 prev_parser.screen().contents_formatted(),
1257 fresh_parser.screen().contents_formatted(),
1258 "Reused parser state after set_size + RIS must match fresh parser"
1259 );
1260 }
1261
1262 #[test]
1263 fn render_frame_outputs_correct_cup_and_sgr() {
1264 let mut parser = term_wm_vt100::Parser::new(4, 8, 0);
1265 parser.process(b"\x1b[31mhello\x1b[0m");
1266 let screen = parser.screen();
1267 let mut buf: Vec<u8> = Vec::new();
1268 let (rows, cols) = screen.size();
1269 render_frame(&mut buf, screen, rows, cols, false).unwrap();
1270 let output = String::from_utf8_lossy(&buf);
1271 assert!(output.contains("\x1b[1;1H"));
1273 assert!(output.contains("\x1b[2;1H"));
1274 assert!(output.contains("\x1b[3;1H"));
1275 assert!(output.contains("\x1b[4;1H"));
1276 assert!(output.contains("hello"));
1278 assert!(
1280 output.contains("\x1b[38;5;1m") || output.contains("\x1b[31m"),
1281 "Expected red foreground SGR in output: {output:?}"
1282 );
1283 assert!(!output.contains("\x1b\x1b"), "no double ESC sequences");
1285 }
1286
1287 #[test]
1290 fn coalesce_moved_with_moved() {
1291 assert!(is_coalescable_mouse(
1292 &MouseEventKind::Moved,
1293 &KeyModifiers::NONE,
1294 &MouseEventKind::Moved,
1295 &KeyModifiers::NONE,
1296 ));
1297 }
1298
1299 #[test]
1300 fn coalesce_drag_same_button() {
1301 assert!(is_coalescable_mouse(
1302 &MouseEventKind::Drag(MouseButton::Left),
1303 &KeyModifiers::NONE,
1304 &MouseEventKind::Drag(MouseButton::Left),
1305 &KeyModifiers::NONE,
1306 ));
1307 assert!(is_coalescable_mouse(
1308 &MouseEventKind::Drag(MouseButton::Right),
1309 &KeyModifiers {
1310 shift: true,
1311 ..KeyModifiers::NONE
1312 },
1313 &MouseEventKind::Drag(MouseButton::Right),
1314 &KeyModifiers {
1315 shift: true,
1316 ..KeyModifiers::NONE
1317 },
1318 ));
1319 }
1320
1321 #[test]
1322 fn reject_drag_different_button() {
1323 assert!(!is_coalescable_mouse(
1324 &MouseEventKind::Drag(MouseButton::Left),
1325 &KeyModifiers::NONE,
1326 &MouseEventKind::Drag(MouseButton::Right),
1327 &KeyModifiers::NONE,
1328 ));
1329 }
1330
1331 #[test]
1332 fn reject_moved_vs_drag() {
1333 assert!(!is_coalescable_mouse(
1334 &MouseEventKind::Moved,
1335 &KeyModifiers::NONE,
1336 &MouseEventKind::Drag(MouseButton::Left),
1337 &KeyModifiers::NONE,
1338 ));
1339 }
1340
1341 #[test]
1342 fn reject_different_modifiers() {
1343 assert!(!is_coalescable_mouse(
1344 &MouseEventKind::Moved,
1345 &KeyModifiers::NONE,
1346 &MouseEventKind::Moved,
1347 &KeyModifiers {
1348 shift: true,
1349 ..KeyModifiers::NONE
1350 },
1351 ));
1352 assert!(!is_coalescable_mouse(
1353 &MouseEventKind::Drag(MouseButton::Left),
1354 &KeyModifiers {
1355 control: true,
1356 ..KeyModifiers::NONE
1357 },
1358 &MouseEventKind::Drag(MouseButton::Left),
1359 &KeyModifiers::NONE,
1360 ));
1361 }
1362
1363 #[test]
1364 fn reject_discrete_events() {
1365 assert!(!is_coalescable_mouse(
1366 &MouseEventKind::Press(MouseButton::Left),
1367 &KeyModifiers::NONE,
1368 &MouseEventKind::Press(MouseButton::Left),
1369 &KeyModifiers::NONE,
1370 ));
1371 assert!(!is_coalescable_mouse(
1372 &MouseEventKind::Release(MouseButton::Left),
1373 &KeyModifiers::NONE,
1374 &MouseEventKind::Moved,
1375 &KeyModifiers::NONE,
1376 ));
1377 assert!(!is_coalescable_mouse(
1378 &MouseEventKind::Moved,
1379 &KeyModifiers::NONE,
1380 &MouseEventKind::ScrollDown,
1381 &KeyModifiers::NONE,
1382 ));
1383 assert!(!is_coalescable_mouse(
1384 &MouseEventKind::ScrollUp,
1385 &KeyModifiers::NONE,
1386 &MouseEventKind::ScrollUp,
1387 &KeyModifiers::NONE,
1388 ));
1389 }
1390
1391 fn coalesce_through(
1396 events: &[Event],
1397 kind: MouseEventKind,
1398 modifiers: KeyModifiers,
1399 ) -> Option<Event> {
1400 let (tx, rx) = crossbeam_channel::bounded::<Event>(events.len());
1401 for e in events.iter().cloned() {
1402 tx.send(e).ok();
1403 }
1404 drop(tx);
1405
1406 let mut result = Event::Mouse(MouseEvent {
1407 kind,
1408 modifiers,
1409 column: 0,
1410 row: 0,
1411 });
1412
1413 if let Event::Mouse(ref mut mouse) = result
1414 && matches!(mouse.kind, MouseEventKind::Moved | MouseEventKind::Drag(_))
1415 {
1416 while let Ok(next) = rx.try_recv() {
1417 match next {
1418 Event::Mouse(ref next_mouse)
1419 if is_coalescable_mouse(
1420 &mouse.kind,
1421 &mouse.modifiers,
1422 &next_mouse.kind,
1423 &next_mouse.modifiers,
1424 ) =>
1425 {
1426 *mouse = *next_mouse;
1427 }
1428 _other => return Some(result),
1429 }
1430 }
1431 }
1432
1433 Some(result)
1434 }
1435
1436 #[test]
1437 fn coalesce_keeps_latest_moved_position() {
1438 let events = vec![
1439 Event::Mouse(MouseEvent {
1440 kind: MouseEventKind::Moved,
1441 modifiers: KeyModifiers::NONE,
1442 column: 5,
1443 row: 5,
1444 }),
1445 Event::Mouse(MouseEvent {
1446 kind: MouseEventKind::Moved,
1447 modifiers: KeyModifiers::NONE,
1448 column: 10,
1449 row: 10,
1450 }),
1451 Event::Mouse(MouseEvent {
1452 kind: MouseEventKind::Moved,
1453 modifiers: KeyModifiers::NONE,
1454 column: 15,
1455 row: 15,
1456 }),
1457 ];
1458 let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1459 let Event::Mouse(m) = result.unwrap() else {
1460 panic!("expected mouse")
1461 };
1462 assert_eq!((m.column, m.row), (15, 15));
1463 }
1464
1465 #[test]
1466 fn coalesce_keeps_latest_drag_position() {
1467 let events = vec![
1468 Event::Mouse(MouseEvent {
1469 kind: MouseEventKind::Drag(MouseButton::Left),
1470 modifiers: KeyModifiers::NONE,
1471 column: 1,
1472 row: 1,
1473 }),
1474 Event::Mouse(MouseEvent {
1475 kind: MouseEventKind::Drag(MouseButton::Left),
1476 modifiers: KeyModifiers::NONE,
1477 column: 2,
1478 row: 2,
1479 }),
1480 ];
1481 let result = coalesce_through(
1482 &events,
1483 MouseEventKind::Drag(MouseButton::Left),
1484 KeyModifiers::NONE,
1485 );
1486 let Event::Mouse(m) = result.unwrap() else {
1487 panic!("expected mouse")
1488 };
1489 assert_eq!((m.column, m.row), (2, 2));
1490 }
1491
1492 #[test]
1493 fn coalesce_stops_at_modifier_change() {
1494 let events = vec![Event::Mouse(MouseEvent {
1495 kind: MouseEventKind::Moved,
1496 modifiers: KeyModifiers {
1497 shift: true,
1498 ..KeyModifiers::NONE
1499 },
1500 column: 99,
1501 row: 99,
1502 })];
1503 let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1504 let Event::Mouse(m) = result.unwrap() else {
1505 panic!("expected mouse")
1506 };
1507 assert_eq!((m.column, m.row), (0, 0));
1510 }
1511
1512 #[test]
1513 fn coalesce_stops_at_non_mouse_event() {
1514 let key = Event::Key(KeyEvent {
1515 code: KeyCode::Char('q'),
1516 kind: KeyKind::Press,
1517 modifiers: KeyModifiers::NONE,
1518 });
1519 let events = vec![key.clone()];
1520 let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1521 let Event::Mouse(m) = result.unwrap() else {
1522 panic!("expected mouse")
1523 };
1524 assert_eq!((m.column, m.row), (0, 0));
1526 }
1527
1528 #[test]
1529 fn coalesce_stops_at_discrete_mouse_event() {
1530 let events = vec![Event::Mouse(MouseEvent {
1531 kind: MouseEventKind::Press(MouseButton::Left),
1532 modifiers: KeyModifiers::NONE,
1533 column: 10,
1534 row: 10,
1535 })];
1536 let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1537 let Event::Mouse(m) = result.unwrap() else {
1538 panic!("expected mouse")
1539 };
1540 assert_eq!((m.column, m.row), (0, 0));
1542 }
1543}
1544
1545#[allow(clippy::unwrap_used)]
1549#[cfg(test)]
1550#[allow(clippy::type_complexity)]
1551mod snapshot_tests {
1552 use super::*;
1553
1554 fn render_and_capture(pty_bytes: &[u8], rows: u16, cols: u16, clear_display: bool) -> Vec<u8> {
1557 let rt = tokio::runtime::Builder::new_current_thread()
1558 .build()
1559 .expect("tokio rt");
1560 let (push_tx, push_rx) = crossbeam_channel::bounded(16);
1561 let input_writer: Box<dyn FnMut(&[u8]) -> io::Result<()> + Send> = Box::new(|_| Ok(()));
1562 let mut pane = RemotePane::new(
1563 0,
1564 None,
1565 rt.handle().clone(),
1566 cols,
1567 rows,
1568 push_rx,
1569 input_writer,
1570 );
1571 drop(rt); push_tx.send(pty_bytes.to_vec()).ok();
1574 pane.drain_pushes();
1575
1576 let parser = pane.shared_parser();
1577 let parser = parser.lock().unwrap();
1578 let screen = parser.screen();
1579 let (rows, cols) = screen.size();
1580 let mut out = Vec::new();
1581 render_frame(&mut out, screen, rows, cols, clear_display).unwrap();
1582 out
1583 }
1584
1585 fn escape_ansi(bytes: &[u8]) -> String {
1587 let mut out: Vec<u8> = Vec::with_capacity(bytes.len() * 4);
1588 for &b in bytes {
1589 match b {
1590 b'\x1b' => out.extend_from_slice(b"\\x1b"),
1591 b'\n' => out.extend_from_slice(b"\\n"),
1592 b'\r' => out.extend_from_slice(b"\\r"),
1593 b'\t' => out.extend_from_slice(b"\\t"),
1594 0x20..=0x7e => out.push(b),
1595 _ => {
1596 out.push(b'\\');
1597 out.push(b'x');
1598 out.extend_from_slice(&hex_byte(b));
1599 }
1600 }
1601 }
1602 unsafe { String::from_utf8_unchecked(out) }
1604 }
1605
1606 fn hex_byte(b: u8) -> [u8; 2] {
1607 #[inline]
1608 fn hex_nibble(n: u8) -> u8 {
1609 let digit = n & 0x0f;
1610 if digit < 10 {
1611 b'0' + digit
1612 } else {
1613 b'a' + digit - 10
1614 }
1615 }
1616 [hex_nibble(b >> 4), hex_nibble(b)]
1617 }
1618
1619 #[test]
1622 fn snapshot_empty_grid() {
1623 let out = render_and_capture(b"", 4, 8, false);
1624 insta::assert_snapshot!("empty_grid", escape_ansi(&out));
1625 }
1626
1627 #[test]
1628 fn snapshot_basic_text() {
1629 let out = render_and_capture(b"Hello\nWorld", 4, 8, false);
1630 insta::assert_snapshot!("basic_text", escape_ansi(&out));
1631 }
1632
1633 #[test]
1634 fn snapshot_colored_text() {
1635 let out = render_and_capture(b"\x1b[31mred\x1b[1mbold", 4, 8, false);
1636 insta::assert_snapshot!("colored_text", escape_ansi(&out));
1637 }
1638
1639 #[test]
1640 fn snapshot_normal_char_at_margin() {
1641 let out = render_and_capture(b"ABCD", 1, 4, false);
1644 insta::assert_snapshot!("normal_char_at_margin", escape_ansi(&out));
1645 }
1646
1647 #[test]
1648 fn snapshot_clear_display() {
1649 let out = render_and_capture(b"", 4, 8, true);
1650 insta::assert_snapshot!("clear_display", escape_ansi(&out));
1651 }
1652
1653 #[test]
1654 fn snapshot_hidden_cursor() {
1655 let out = render_and_capture(b"\x1b[?25l", 4, 8, false);
1656 insta::assert_snapshot!("hidden_cursor", escape_ansi(&out));
1657 }
1658
1659 #[test]
1660 fn snapshot_color_across_margin() {
1661 let out = render_and_capture(b"\x1b[41mX", 1, 4, false);
1664 insta::assert_snapshot!("color_across_margin", escape_ansi(&out));
1665 }
1666
1667 #[test]
1668 fn snapshot_multi_row_fill() {
1669 let out = render_and_capture(b"ABCDEFGHIJKL", 3, 4, false);
1672 insta::assert_snapshot!("multi_row_fill", escape_ansi(&out));
1673 }
1674
1675 #[test]
1676 fn snapshot_wide_char_margin() {
1677 let out = render_and_capture(
1680 b"B\xe3\x81\x82", 1,
1682 3,
1683 false,
1684 );
1685 insta::assert_snapshot!("wide_char_margin", escape_ansi(&out));
1686 }
1687
1688 #[test]
1689 fn snapshot_wide_char_middle() {
1690 let out = render_and_capture(
1694 b"A\xe3\x81\x82\xe3\x81\x83", 1,
1696 5,
1697 false,
1698 );
1699 insta::assert_snapshot!("wide_char_middle", escape_ansi(&out));
1700 }
1701}