mod remote_pane;
pub use remote_pane::RemotePane;
use std::io::{self, IsTerminal, Write, stdout};
#[cfg(unix)]
use std::os::unix::io::FromRawFd;
use std::sync::atomic::{AtomicBool, AtomicU16, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use crossterm::QueueableCommand;
use crossterm::cursor::{Hide, Show};
use crossterm::event::{DisableBracketedPaste, EnableBracketedPaste};
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use muxio_rpc_service_endpoint::RpcServiceEndpointInterface;
use muxio_tokio_mpsc_adapter::ChannelCallerExt;
use muxio_tokio_rpc_ipc_client::{RpcCallPrebuffered, RpcIpcClient, RpcServiceCallerInterface};
use portable_pty::PtySize;
use term_session_muxio_service_definitions::{
OnPtyResized, RpcMethodPrebuffered, STREAM_INPUT_METHOD_ID, SUBSCRIBE_OUTPUT_METHOD_ID, Spawn,
};
use term_wm_events::{Event, KeyKind, KeyModifiers, MouseEventKind};
use term_wm_pty_engine::Pane;
use term_wm_pty_engine::clipboard::{Clipboard, Osc52Extractor};
use term_wm_pty_engine::input_encoding::{key_to_bytes, mouse_event_to_bytes};
use term_wm_pty_engine::signal::install_sigint_handler;
use vt100::{MouseProtocolEncoding, MouseProtocolMode, Parser, Screen};
#[cfg(unix)]
pub fn redirect_fd_to_tracing(target_fd: libc::c_int, is_stderr: bool) -> std::io::Result<()> {
let mut fds: [libc::c_int; 2] = [0; 2];
unsafe {
if libc::pipe(fds.as_mut_ptr()) == -1 {
return Err(std::io::Error::last_os_error());
}
if libc::dup2(fds[1], target_fd) == -1 {
libc::close(fds[0]);
libc::close(fds[1]);
return Err(std::io::Error::last_os_error());
}
libc::close(fds[1]);
}
let read_fd = fds[0];
let name = if is_stderr {
"stderr-tracing"
} else {
"stdout-tracing"
};
std::thread::Builder::new()
.name(name.into())
.spawn(move || {
use std::io::BufRead;
let file = unsafe { std::fs::File::from_raw_fd(read_fd) };
let mut reader = std::io::BufReader::new(file);
let mut buf = Vec::new();
while reader.read_until(b'\n', &mut buf).unwrap_or(0) > 0 {
let text = String::from_utf8_lossy(&buf);
let trimmed = text.trim();
if !trimmed.is_empty() {
if is_stderr {
tracing::error!(target: "c_stderr", "{}", trimmed);
} else {
tracing::info!(target: "c_stdout", "{}", trimmed);
}
}
buf.clear();
}
})?;
Ok(())
}
#[cfg(windows)]
fn disable_quick_edit() {
use windows_sys::Win32::System::Console::{
ENABLE_EXTENDED_FLAGS, ENABLE_QUICK_EDIT_MODE, GetConsoleMode, GetStdHandle,
STD_INPUT_HANDLE, SetConsoleMode,
};
unsafe {
let handle = GetStdHandle(STD_INPUT_HANDLE);
let mut mode: u32 = 0;
if GetConsoleMode(handle, &mut mode) != 0 {
let _ = SetConsoleMode(
handle,
(mode & !ENABLE_QUICK_EDIT_MODE) | ENABLE_EXTENDED_FLAGS,
);
}
}
}
#[cfg(target_os = "windows")]
const INITIAL_WAIT_ITERS: usize = 60;
#[cfg(not(target_os = "windows"))]
const INITIAL_WAIT_ITERS: usize = 20;
const PTY_OUTPUT_CHANNEL_CAPACITY: usize = 256;
const CLIPBOARD_CHANNEL_CAPACITY: usize = 64;
const INPUT_CHANNEL_CAPACITY: usize = 64;
const PREV_TAIL_LEN: usize = 8;
const INITIAL_WAIT_SLEEP_MS: u64 = 50;
const INPUT_POLL_MS: u64 = 50;
const BACKPRESSURE_SLEEP_MS: u64 = 1;
const BRACKETED_PASTE_OVERHEAD: usize = 12;
const RENDER_BUF_CELL_MULTIPLIER: usize = 3;
pub fn init_terminal<W: Write>(mut writer: W) -> io::Result<TerminalGuard<W>> {
if std::io::stdin().is_terminal() {
enable_raw_mode()?;
}
writer.queue(EnterAlternateScreen)?;
writer.queue(Hide)?;
writer.queue(EnableBracketedPaste)?;
writer.queue(crossterm::event::EnableMouseCapture)?;
writer.flush()?;
Ok(TerminalGuard {
writer: Some(writer),
})
}
pub struct TerminalGuard<W: Write = std::io::Stdout> {
writer: Option<W>,
}
impl<W: Write> Drop for TerminalGuard<W> {
fn drop(&mut self) {
if let Some(ref mut writer) = self.writer {
let _ = writer.queue(crossterm::event::DisableMouseCapture);
let _ = writer.queue(DisableBracketedPaste);
let _ = writer.queue(Show);
let _ = writer.queue(LeaveAlternateScreen);
if std::io::stdin().is_terminal() {
let _ = disable_raw_mode();
}
let _ = writer.flush();
}
}
}
fn convert_crossterm_event(evt: crossterm::event::Event) -> Option<Event> {
term_wm_crossterm_adapter::try_translate_event(evt)
}
fn is_coalescable_mouse(
a_kind: &MouseEventKind,
a_mod: &KeyModifiers,
b_kind: &MouseEventKind,
b_mod: &KeyModifiers,
) -> bool {
if a_mod != b_mod {
return false;
}
match (a_kind, b_kind) {
(MouseEventKind::Moved, MouseEventKind::Moved) => true,
(MouseEventKind::Drag(btn1), MouseEventKind::Drag(btn2)) => btn1 == btn2,
_ => false,
}
}
pub fn run_session(socket_path: &str) -> io::Result<()> {
#[cfg(windows)]
disable_quick_edit();
#[cfg(unix)]
let _ = redirect_fd_to_tracing(libc::STDERR_FILENO, true);
let rt =
tokio::runtime::Runtime::new().map_err(|e| io::Error::other(format!("runtime: {e}")))?;
let client: Arc<RpcIpcClient> = rt
.block_on(RpcIpcClient::new(socket_path))
.map_err(|e| io::Error::new(io::ErrorKind::ConnectionRefused, format!("{e:?}")))?;
let server_cols = Arc::new(AtomicU16::new(0));
let server_rows = Arc::new(AtomicU16::new(0));
let resize_pending = Arc::new(AtomicBool::new(false));
{
let cols_ref = Arc::clone(&server_cols);
let rows_ref = Arc::clone(&server_rows);
let pending_ref = Arc::clone(&resize_pending);
rt.block_on(client.get_endpoint().register_prebuffered(
OnPtyResized::METHOD_ID,
move |payload, _ctx| {
let cols_ref = Arc::clone(&cols_ref);
let rows_ref = Arc::clone(&rows_ref);
let pending_ref = Arc::clone(&pending_ref);
async move {
let (cols, rows) = OnPtyResized::decode_request(&payload)
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
cols_ref.store(cols, Ordering::Relaxed);
rows_ref.store(rows, Ordering::Relaxed);
pending_ref.store(true, Ordering::Relaxed);
OnPtyResized::encode_response(())
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
}
},
))
.map_err(|e| io::Error::other(format!("register OnPtyResized: {e:?}")))?;
}
let (push_tx, push_rx) = crossbeam_channel::bounded::<Vec<u8>>(PTY_OUTPUT_CHANNEL_CAPACITY);
let (clip_tx, clip_rx) = crossbeam_channel::bounded::<String>(CLIPBOARD_CHANNEL_CAPACITY);
let (term_cols, term_rows) = crossterm::terminal::size()?;
let (_session_id, actual_cols, actual_rows) = rt.block_on(async {
Spawn::call(&*client, (None, term_cols, term_rows))
.await
.map_err(|e| io::Error::other(format!("spawn: {e:?}")))
})?;
let writer = rt.block_on(async {
let (_, mut reader) = client
.open_channel(SUBSCRIBE_OUTPUT_METHOD_ID, 0)
.await
.map_err(|e| io::Error::other(format!("subscribe: {e:?}")))?;
rt.spawn(async move {
let mut osc52 = Osc52Extractor::new();
let mut prev_tail: [u8; PREV_TAIL_LEN] = [0; PREV_TAIL_LEN];
while let Some(chunk) = reader.recv().await {
if let Ok(mut data) = chunk {
if let Some(text) = osc52.push(&data, &prev_tail) {
let _ = clip_tx.try_send(text);
}
let n = data.len();
if n >= PREV_TAIL_LEN {
prev_tail.copy_from_slice(&data[n - PREV_TAIL_LEN..n]);
} else if n > 0 {
prev_tail.rotate_left(n);
prev_tail[PREV_TAIL_LEN - n..].copy_from_slice(&data[..n]);
}
while let Err(crossbeam_channel::TrySendError::Full(pending)) =
push_tx.try_send(data)
{
data = pending;
tokio::time::sleep(Duration::from_millis(BACKPRESSURE_SLEEP_MS)).await;
}
} else {
break;
}
}
});
let (writer, _) = client
.open_channel(STREAM_INPUT_METHOD_ID, 0)
.await
.map_err(|e| io::Error::other(format!("stream input: {e:?}")))?;
Ok::<_, io::Error>(writer)
})?;
let input_writer = Box::new(move |data: &[u8]| -> io::Result<()> {
writer
.send(data.to_vec())
.map_err(|e| io::Error::other(e.to_string()))?;
Ok(())
});
let mut pane = RemotePane::new(
1u64,
Some(client.clone()),
rt.handle().clone(),
term_cols,
term_rows,
push_rx.clone(),
input_writer,
);
for _ in 0..INITIAL_WAIT_ITERS {
pane.drain_pushes();
let parser = pane.shared_parser();
let parser = parser.lock().unwrap();
if !parser.screen().contents_formatted().is_empty() {
break;
}
drop(parser);
std::thread::sleep(Duration::from_millis(INITIAL_WAIT_SLEEP_MS));
}
{
let parser = pane.shared_parser();
let mut parser_lk = parser.lock().unwrap();
let (cur_rows, cur_cols) = parser_lk.screen().size();
if actual_cols != cur_cols || actual_rows != cur_rows {
parser_lk.screen_mut().set_size(actual_rows, actual_cols);
}
drop(parser_lk);
}
let _guard = init_terminal(stdout())?;
let mut out = stdout();
let mut clipboard = Clipboard::new();
let sigint = install_sigint_handler()?;
let (input_tx, input_rx) = crossbeam_channel::bounded::<Event>(INPUT_CHANNEL_CAPACITY);
std::thread::Builder::new()
.name("crossterm-input".into())
.spawn(move || {
loop {
match crossterm::event::poll(Duration::from_millis(INPUT_POLL_MS)) {
Ok(true) => {
if let Ok(crossterm_evt) = crossterm::event::read()
&& let Some(e) = convert_crossterm_event(crossterm_evt)
&& input_tx.send(e).is_err()
{
break;
}
}
Ok(false) => continue,
Err(_) => break,
}
}
})
.map_err(|e| io::Error::other(format!("spawn input thread: {e}")))?;
{
let parser = pane.shared_parser();
let parser = parser.lock().unwrap();
let screen = parser.screen();
let (rows, cols) = screen.size();
render_frame(&mut out, screen, rows, cols, false)?;
}
let mut pending_input: Option<Event> = None;
loop {
let mut force_render = false;
let mut clear_display = false;
let apply_pending_resize = |shared_parser: &Arc<Mutex<Parser>>| -> bool {
if resize_pending.swap(false, Ordering::Relaxed) {
let cols = server_cols.load(Ordering::Relaxed);
let rows = server_rows.load(Ordering::Relaxed);
if cols > 0 && rows > 0 {
let mut parser_lk = shared_parser.lock().unwrap();
let (cur_rows, cur_cols) = parser_lk.screen().size();
if cur_cols != cols || cur_rows != rows {
parser_lk.screen_mut().set_size(rows, cols);
return true;
}
}
}
false
};
let resized = apply_pending_resize(&pane.shared_parser());
force_render |= resized;
clear_display |= resized;
let input_event = if let Some(evt) = pending_input.take() {
Some(evt)
} else {
crossbeam_channel::select! {
recv(input_rx) -> msg => {
match msg {
Ok(evt) => Some(evt),
Err(_) => return Err(io::Error::other("input thread died")),
}
}
recv(push_rx) -> msg => {
match msg {
Ok(data) => {
let resized = apply_pending_resize(&pane.shared_parser());
force_render |= resized;
clear_display |= resized;
let parser = pane.shared_parser();
let mut parser = parser.lock().unwrap();
parser.process(&data);
None
}
Err(_) => {
None
}
}
}
}
};
let has_new_data = pane.drain_pushes() || input_event.is_none();
while let Ok(text) = clip_rx.try_recv() {
let _ = clipboard.set(&text);
}
if sigint.received() {
sigint.ack();
let _ = pane.write_bytes(&[0x03]);
}
if let Some(mut evt) = input_event {
if let Event::Mouse(ref mut mouse) = evt
&& matches!(mouse.kind, MouseEventKind::Moved | MouseEventKind::Drag(_))
{
while let Ok(next_evt) = input_rx.try_recv() {
match next_evt {
Event::Mouse(ref next_mouse)
if is_coalescable_mouse(
&mouse.kind,
&mouse.modifiers,
&next_mouse.kind,
&next_mouse.modifiers,
) =>
{
*mouse = *next_mouse;
}
other => {
pending_input = Some(other);
break;
}
}
}
}
match evt {
Event::Key(ref key)
if key.kind == KeyKind::Press || key.kind == KeyKind::Repeat =>
{
let bytes = key_to_bytes(key, false);
if !bytes.is_empty() {
let _ = pane.write_bytes(&bytes);
}
}
Event::Mouse(ref mouse) => {
let mouse_active = {
let parser = pane.shared_parser();
let parser = parser.lock().unwrap();
parser.screen().mouse_protocol_mode() != MouseProtocolMode::None
};
if mouse_active {
let bytes = mouse_event_to_bytes(mouse, MouseProtocolEncoding::Sgr);
if !bytes.is_empty() {
let _ = pane.write_bytes(&bytes);
}
}
}
Event::Resize(w, h) => {
let size = PtySize {
rows: h,
cols: w,
pixel_width: 0,
pixel_height: 0,
};
if let Err(err) = pane.resize(size) {
tracing::warn!(error = %err, "resize request failed on PTY pane");
}
force_render = true;
clear_display = true;
}
Event::Paste(text) => {
let mut wrapped = Vec::with_capacity(text.len() + BRACKETED_PASTE_OVERHEAD);
wrapped.extend_from_slice(b"\x1b[200~");
wrapped.extend_from_slice(text.as_bytes());
wrapped.extend_from_slice(b"\x1b[201~");
let _ = pane.write_bytes(&wrapped);
}
_ => {}
}
}
if !client.is_connected() {
return Err(io::Error::other("connection to session server lost"));
}
if has_new_data || force_render {
let parser = pane.shared_parser();
let parser = parser.lock().unwrap();
let screen = parser.screen();
let (rows, cols) = screen.size();
render_frame(&mut out, screen, rows, cols, clear_display)?;
}
if pane.has_exited() {
return Ok(());
}
}
}
#[derive(Default, PartialEq, Clone, Copy)]
struct CellStyle {
fg: vt100::Color,
bg: vt100::Color,
bold: bool,
dim: bool,
italic: bool,
underline: bool,
inverse: bool,
}
impl CellStyle {
fn from_cell(cell: &vt100::Cell) -> Self {
Self {
fg: cell.fgcolor(),
bg: cell.bgcolor(),
bold: cell.bold(),
dim: cell.dim(),
italic: cell.italic(),
underline: cell.underline(),
inverse: cell.inverse(),
}
}
}
fn apply_sgr(out: &mut dyn Write, style: &CellStyle) -> io::Result<()> {
write!(out, "\x1b[0m")?;
if style.bold {
write!(out, "\x1b[1m")?;
}
if style.dim {
write!(out, "\x1b[2m")?;
}
if style.italic {
write!(out, "\x1b[3m")?;
}
if style.underline {
write!(out, "\x1b[4m")?;
}
if style.inverse {
write!(out, "\x1b[7m")?;
}
match style.fg {
vt100::Color::Idx(i) => write!(out, "\x1b[38;5;{}m", i)?,
vt100::Color::Rgb(r, g, b) => write!(out, "\x1b[38;2;{};{};{}m", r, g, b)?,
_ => {}
}
match style.bg {
vt100::Color::Idx(i) => write!(out, "\x1b[48;5;{}m", i)?,
vt100::Color::Rgb(r, g, b) => write!(out, "\x1b[48;2;{};{};{}m", r, g, b)?,
_ => {}
}
Ok(())
}
pub fn render_frame(
out: &mut dyn Write,
screen: &Screen,
rows: u16,
cols: u16,
clear_display: bool,
) -> io::Result<()> {
let mut buf =
Vec::with_capacity((rows as usize) * (cols as usize) * RENDER_BUF_CELL_MULTIPLIER);
let mut active_style = CellStyle::default();
buf.extend_from_slice(b"\x1b[?2026h\x1b[?25l\x1b[0m");
if clear_display {
buf.extend_from_slice(b"\x1b[2J");
}
buf.extend_from_slice(b"\x1b[?7l");
for row in 0..rows {
write!(buf, "\x1b[{};1H", row + 1)?;
let mut col: u16 = 0;
while col < cols {
let cell_opt = screen.cell(row, col);
let contents = cell_opt.map_or("", |c| c.contents());
let width = if contents.is_empty() {
1
} else {
unicode_width::UnicodeWidthStr::width(contents).max(1) as u16
};
if col + width >= cols {
buf.extend_from_slice(b"\x1b[0m\x1b[K");
active_style = CellStyle::default();
}
let style = cell_opt.map(CellStyle::from_cell).unwrap_or_default();
if style != active_style {
apply_sgr(&mut buf, &style)?;
active_style = style;
}
if contents.is_empty() {
buf.push(b' ');
} else {
buf.extend_from_slice(contents.as_bytes());
}
col += width;
}
}
buf.extend_from_slice(b"\x1b[?7h");
buf.extend_from_slice(b"\x1b[0m");
let (cur_row, cur_col) = screen.cursor_position();
write!(buf, "\x1b[{};{}H", cur_row + 1, cur_col + 1)?;
if screen.hide_cursor() {
buf.extend_from_slice(b"\x1b[?25l");
} else {
buf.extend_from_slice(b"\x1b[?25h");
}
buf.extend_from_slice(b"\x1b[?2026l");
out.write_all(&buf)?;
out.flush()
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
use term_wm_events::{KeyCode, KeyEvent, MouseButton, MouseEvent};
struct TestWriter {
buf: Arc<Mutex<Vec<u8>>>,
}
impl TestWriter {
fn new() -> (Self, Arc<Mutex<Vec<u8>>>) {
let buf = Arc::new(Mutex::new(Vec::new()));
(Self { buf: buf.clone() }, buf)
}
}
impl Write for TestWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.buf.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[test]
fn init_terminal_writes_bracketed_paste_enable() {
let (writer, buf) = TestWriter::new();
let _guard = init_terminal(writer).expect("init_terminal");
let bytes = buf.lock().unwrap();
assert!(
bytes
.windows(b"\x1b[?2004h".len())
.any(|w| w == b"\x1b[?2004h")
);
}
#[test]
fn terminal_guard_teardown_writes_bracketed_paste_disable() {
let (writer, buf) = TestWriter::new();
{
let _guard = TerminalGuard {
writer: Some(writer),
};
}
let bytes = buf.lock().unwrap();
assert!(
bytes
.windows(b"\x1b[?2004l".len())
.any(|w| w == b"\x1b[?2004l")
);
}
#[test]
fn init_and_teardown_roundtrip_contains_both_sequences() {
let (writer, buf) = TestWriter::new();
let guard = init_terminal(writer).expect("init_terminal");
drop(guard);
let bytes = buf.lock().unwrap();
assert!(
bytes
.windows(b"\x1b[?2004h".len())
.any(|w| w == b"\x1b[?2004h")
);
assert!(
bytes
.windows(b"\x1b[?2004l".len())
.any(|w| w == b"\x1b[?2004l")
);
}
#[test]
fn test_prev_parser_resize_sync_matches_fresh_parser() {
let mut prev_parser = vt100::Parser::new(24, 80, 0);
prev_parser.process(b"initial screen content");
let (new_rows, new_cols) = (40, 120);
let new_formatted_content = {
let mut p = vt100::Parser::new(new_rows, new_cols, 0);
p.process(b"resized screen content");
p.screen().contents_formatted().to_vec()
};
prev_parser.screen_mut().set_size(new_rows, new_cols);
prev_parser.process(b"\x1bc");
prev_parser.process(&new_formatted_content);
let mut fresh_parser = vt100::Parser::new(new_rows, new_cols, 0);
fresh_parser.process(&new_formatted_content);
assert_eq!(
prev_parser.screen().contents_formatted(),
fresh_parser.screen().contents_formatted(),
"Reused parser state after set_size + RIS must match fresh parser"
);
}
#[test]
fn render_frame_outputs_correct_cup_and_sgr() {
let mut parser = vt100::Parser::new(4, 8, 0);
parser.process(b"\x1b[31mhello\x1b[0m");
let screen = parser.screen();
let mut buf: Vec<u8> = Vec::new();
let (rows, cols) = screen.size();
render_frame(&mut buf, screen, rows, cols, false).unwrap();
let output = String::from_utf8_lossy(&buf);
assert!(output.contains("\x1b[1;1H"));
assert!(output.contains("\x1b[2;1H"));
assert!(output.contains("\x1b[3;1H"));
assert!(output.contains("\x1b[4;1H"));
assert!(output.contains("hello"));
assert!(
output.contains("\x1b[38;5;1m") || output.contains("\x1b[31m"),
"Expected red foreground SGR in output: {output:?}"
);
assert!(!output.contains("\x1b\x1b"), "no double ESC sequences");
}
#[test]
fn coalesce_moved_with_moved() {
assert!(is_coalescable_mouse(
&MouseEventKind::Moved,
&KeyModifiers::NONE,
&MouseEventKind::Moved,
&KeyModifiers::NONE,
));
}
#[test]
fn coalesce_drag_same_button() {
assert!(is_coalescable_mouse(
&MouseEventKind::Drag(MouseButton::Left),
&KeyModifiers::NONE,
&MouseEventKind::Drag(MouseButton::Left),
&KeyModifiers::NONE,
));
assert!(is_coalescable_mouse(
&MouseEventKind::Drag(MouseButton::Right),
&KeyModifiers {
shift: true,
..KeyModifiers::NONE
},
&MouseEventKind::Drag(MouseButton::Right),
&KeyModifiers {
shift: true,
..KeyModifiers::NONE
},
));
}
#[test]
fn reject_drag_different_button() {
assert!(!is_coalescable_mouse(
&MouseEventKind::Drag(MouseButton::Left),
&KeyModifiers::NONE,
&MouseEventKind::Drag(MouseButton::Right),
&KeyModifiers::NONE,
));
}
#[test]
fn reject_moved_vs_drag() {
assert!(!is_coalescable_mouse(
&MouseEventKind::Moved,
&KeyModifiers::NONE,
&MouseEventKind::Drag(MouseButton::Left),
&KeyModifiers::NONE,
));
}
#[test]
fn reject_different_modifiers() {
assert!(!is_coalescable_mouse(
&MouseEventKind::Moved,
&KeyModifiers::NONE,
&MouseEventKind::Moved,
&KeyModifiers {
shift: true,
..KeyModifiers::NONE
},
));
assert!(!is_coalescable_mouse(
&MouseEventKind::Drag(MouseButton::Left),
&KeyModifiers {
control: true,
..KeyModifiers::NONE
},
&MouseEventKind::Drag(MouseButton::Left),
&KeyModifiers::NONE,
));
}
#[test]
fn reject_discrete_events() {
assert!(!is_coalescable_mouse(
&MouseEventKind::Press(MouseButton::Left),
&KeyModifiers::NONE,
&MouseEventKind::Press(MouseButton::Left),
&KeyModifiers::NONE,
));
assert!(!is_coalescable_mouse(
&MouseEventKind::Release(MouseButton::Left),
&KeyModifiers::NONE,
&MouseEventKind::Moved,
&KeyModifiers::NONE,
));
assert!(!is_coalescable_mouse(
&MouseEventKind::Moved,
&KeyModifiers::NONE,
&MouseEventKind::ScrollDown,
&KeyModifiers::NONE,
));
assert!(!is_coalescable_mouse(
&MouseEventKind::ScrollUp,
&KeyModifiers::NONE,
&MouseEventKind::ScrollUp,
&KeyModifiers::NONE,
));
}
fn coalesce_through(
events: &[Event],
kind: MouseEventKind,
modifiers: KeyModifiers,
) -> Option<Event> {
let (tx, rx) = crossbeam_channel::bounded::<Event>(events.len());
for e in events.iter().cloned() {
tx.send(e).ok();
}
drop(tx);
let mut result = Event::Mouse(MouseEvent {
kind,
modifiers,
column: 0,
row: 0,
});
if let Event::Mouse(ref mut mouse) = result
&& matches!(mouse.kind, MouseEventKind::Moved | MouseEventKind::Drag(_))
{
while let Ok(next) = rx.try_recv() {
match next {
Event::Mouse(ref next_mouse)
if is_coalescable_mouse(
&mouse.kind,
&mouse.modifiers,
&next_mouse.kind,
&next_mouse.modifiers,
) =>
{
*mouse = *next_mouse;
}
_other => return Some(result),
}
}
}
Some(result)
}
#[test]
fn coalesce_keeps_latest_moved_position() {
let events = vec![
Event::Mouse(MouseEvent {
kind: MouseEventKind::Moved,
modifiers: KeyModifiers::NONE,
column: 5,
row: 5,
}),
Event::Mouse(MouseEvent {
kind: MouseEventKind::Moved,
modifiers: KeyModifiers::NONE,
column: 10,
row: 10,
}),
Event::Mouse(MouseEvent {
kind: MouseEventKind::Moved,
modifiers: KeyModifiers::NONE,
column: 15,
row: 15,
}),
];
let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
let Event::Mouse(m) = result.unwrap() else {
panic!("expected mouse")
};
assert_eq!((m.column, m.row), (15, 15));
}
#[test]
fn coalesce_keeps_latest_drag_position() {
let events = vec![
Event::Mouse(MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
modifiers: KeyModifiers::NONE,
column: 1,
row: 1,
}),
Event::Mouse(MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
modifiers: KeyModifiers::NONE,
column: 2,
row: 2,
}),
];
let result = coalesce_through(
&events,
MouseEventKind::Drag(MouseButton::Left),
KeyModifiers::NONE,
);
let Event::Mouse(m) = result.unwrap() else {
panic!("expected mouse")
};
assert_eq!((m.column, m.row), (2, 2));
}
#[test]
fn coalesce_stops_at_modifier_change() {
let events = vec![Event::Mouse(MouseEvent {
kind: MouseEventKind::Moved,
modifiers: KeyModifiers {
shift: true,
..KeyModifiers::NONE
},
column: 99,
row: 99,
})];
let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
let Event::Mouse(m) = result.unwrap() else {
panic!("expected mouse")
};
assert_eq!((m.column, m.row), (0, 0));
}
#[test]
fn coalesce_stops_at_non_mouse_event() {
let key = Event::Key(KeyEvent {
code: KeyCode::Char('q'),
kind: KeyKind::Press,
modifiers: KeyModifiers::NONE,
});
let events = vec![key.clone()];
let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
let Event::Mouse(m) = result.unwrap() else {
panic!("expected mouse")
};
assert_eq!((m.column, m.row), (0, 0));
}
#[test]
fn coalesce_stops_at_discrete_mouse_event() {
let events = vec![Event::Mouse(MouseEvent {
kind: MouseEventKind::Press(MouseButton::Left),
modifiers: KeyModifiers::NONE,
column: 10,
row: 10,
})];
let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
let Event::Mouse(m) = result.unwrap() else {
panic!("expected mouse")
};
assert_eq!((m.column, m.row), (0, 0));
}
}
#[cfg(test)]
#[allow(clippy::type_complexity)]
mod snapshot_tests {
use super::*;
fn render_and_capture(pty_bytes: &[u8], rows: u16, cols: u16, clear_display: bool) -> Vec<u8> {
let rt = tokio::runtime::Builder::new_current_thread()
.build()
.expect("tokio rt");
let (push_tx, push_rx) = crossbeam_channel::bounded(16);
let input_writer: Box<dyn FnMut(&[u8]) -> io::Result<()> + Send> = Box::new(|_| Ok(()));
let mut pane = RemotePane::new(
0,
None,
rt.handle().clone(),
cols,
rows,
push_rx,
input_writer,
);
drop(rt);
push_tx.send(pty_bytes.to_vec()).ok();
pane.drain_pushes();
let parser = pane.shared_parser();
let parser = parser.lock().unwrap();
let screen = parser.screen();
let (rows, cols) = screen.size();
let mut out = Vec::new();
render_frame(&mut out, screen, rows, cols, clear_display).unwrap();
out
}
fn escape_ansi(bytes: &[u8]) -> String {
let mut out: Vec<u8> = Vec::with_capacity(bytes.len() * 4);
for &b in bytes {
match b {
b'\x1b' => out.extend_from_slice(b"\\x1b"),
b'\n' => out.extend_from_slice(b"\\n"),
b'\r' => out.extend_from_slice(b"\\r"),
b'\t' => out.extend_from_slice(b"\\t"),
0x20..=0x7e => out.push(b),
_ => {
out.push(b'\\');
out.push(b'x');
out.extend_from_slice(&hex_byte(b));
}
}
}
unsafe { String::from_utf8_unchecked(out) }
}
fn hex_byte(b: u8) -> [u8; 2] {
#[inline]
fn hex_nibble(n: u8) -> u8 {
let digit = n & 0x0f;
if digit < 10 {
b'0' + digit
} else {
b'a' + digit - 10
}
}
[hex_nibble(b >> 4), hex_nibble(b)]
}
#[test]
fn snapshot_empty_grid() {
let out = render_and_capture(b"", 4, 8, false);
insta::assert_snapshot!("empty_grid", escape_ansi(&out));
}
#[test]
fn snapshot_basic_text() {
let out = render_and_capture(b"Hello\nWorld", 4, 8, false);
insta::assert_snapshot!("basic_text", escape_ansi(&out));
}
#[test]
fn snapshot_colored_text() {
let out = render_and_capture(b"\x1b[31mred\x1b[1mbold", 4, 8, false);
insta::assert_snapshot!("colored_text", escape_ansi(&out));
}
#[test]
fn snapshot_normal_char_at_margin() {
let out = render_and_capture(b"ABCD", 1, 4, false);
insta::assert_snapshot!("normal_char_at_margin", escape_ansi(&out));
}
#[test]
fn snapshot_clear_display() {
let out = render_and_capture(b"", 4, 8, true);
insta::assert_snapshot!("clear_display", escape_ansi(&out));
}
#[test]
fn snapshot_hidden_cursor() {
let out = render_and_capture(b"\x1b[?25l", 4, 8, false);
insta::assert_snapshot!("hidden_cursor", escape_ansi(&out));
}
#[test]
fn snapshot_color_across_margin() {
let out = render_and_capture(b"\x1b[41mX", 1, 4, false);
insta::assert_snapshot!("color_across_margin", escape_ansi(&out));
}
#[test]
fn snapshot_multi_row_fill() {
let out = render_and_capture(b"ABCDEFGHIJKL", 3, 4, false);
insta::assert_snapshot!("multi_row_fill", escape_ansi(&out));
}
#[test]
fn snapshot_wide_char_margin() {
let out = render_and_capture(
b"B\xe3\x81\x82", 1,
3,
false,
);
insta::assert_snapshot!("wide_char_margin", escape_ansi(&out));
}
#[test]
fn snapshot_wide_char_middle() {
let out = render_and_capture(
b"A\xe3\x81\x82\xe3\x81\x83", 1,
5,
false,
);
insta::assert_snapshot!("wide_char_middle", escape_ansi(&out));
}
}