use std::collections::VecDeque;
use std::ffi::{OsStr, OsString};
use std::fmt;
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{mpsc, Arc, Mutex, PoisonError};
use std::thread;
use std::time::{Duration, Instant};
use portable_pty::{native_pty_system, CommandBuilder, PtyPair, PtySize};
use crate::emu::{Emulator, InputModes, MouseEncoding, Query, Stop, Vt100Emulator};
use crate::error::{Error, Result};
use crate::keys::Input;
use crate::keys::{mouse_legacy, mouse_sgr, mouse_utf8};
use crate::screen::{MouseMode, Screen};
use crate::wait::{next_backoff, Expired, Monitor, INITIAL_BACKOFF, POLL_CAP};
const DRAIN_GRACE: Duration = Duration::from_millis(500);
const MAX_UNANSWERED: usize = 8;
const FRAME_HISTORY: usize = 8;
const FRAME_TIMING_HISTORY: usize = 512;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FrameTiming {
index: u64,
duration: Duration,
printable: u32,
}
impl FrameTiming {
#[must_use]
pub fn index(&self) -> u64 {
self.index
}
#[must_use]
pub fn duration(&self) -> Duration {
self.duration
}
#[must_use]
pub fn printable_chars(&self) -> u32 {
self.printable
}
}
const DEFAULT_SCROLLBACK: usize = 1000;
use crate::graphics::DEFAULT_CAPTURE;
const REPLY_QUEUE_BYTES: usize = 1 << 20;
struct WriteRequest {
bytes: Vec<u8>,
ack: Option<mpsc::SyncSender<io::Result<()>>>,
replies: usize,
}
const DROP_REAP_GRACE: Duration = Duration::from_secs(2);
static PTY_LIFECYCLE: Mutex<()> = Mutex::new(());
fn pty_lifecycle_guard() -> std::sync::MutexGuard<'static, ()> {
PTY_LIFECYCLE.lock().unwrap_or_else(PoisonError::into_inner)
}
const PTY_OPEN_ATTEMPTS: u32 = 12;
const PTY_OPEN_BACKOFF: Duration = Duration::from_millis(25);
fn open_pty(size: PtySize) -> Result<(PtyPair, std::sync::MutexGuard<'static, ()>)> {
let pty = native_pty_system();
let mut last = String::new();
for attempt in 0..PTY_OPEN_ATTEMPTS {
let lifecycle = pty_lifecycle_guard();
match pty.openpty(size) {
Ok(pair) => return Ok((pair, lifecycle)),
Err(error) => {
drop(lifecycle);
last = error.to_string();
if attempt + 1 < PTY_OPEN_ATTEMPTS {
thread::sleep(PTY_OPEN_BACKOFF * (attempt + 1));
}
}
}
}
Err(Error::Pty(format!(
"openpty failed after {PTY_OPEN_ATTEMPTS} attempts: {last}"
)))
}
type SharedWriter = Arc<Mutex<Option<Box<dyn Write + Send>>>>;
#[cfg(unix)]
fn dup_writer(master: &dyn portable_pty::MasterPty) -> Option<std::fs::File> {
use std::os::unix::io::FromRawFd;
let fd = master.as_raw_fd()?;
#[allow(unsafe_code)]
let duped = unsafe { libc::dup(fd) };
if duped < 0 {
return None;
}
#[allow(unsafe_code)]
Some(unsafe { std::fs::File::from_raw_fd(duped) })
}
#[cfg(not(unix))]
fn dup_writer(_master: &dyn portable_pty::MasterPty) -> Option<std::fs::File> {
None
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum Graphics {
#[default]
None,
Sixel,
Kitty,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scroll {
Up,
Down,
Left,
Right,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MouseButton {
Left,
Middle,
Right,
}
impl MouseButton {
fn code(self) -> u8 {
match self {
MouseButton::Left => 0,
MouseButton::Middle => 1,
MouseButton::Right => 2,
}
}
#[must_use]
pub fn ctrl(self) -> MouseChord {
MouseChord::from(self).ctrl()
}
#[must_use]
pub fn alt(self) -> MouseChord {
MouseChord::from(self).alt()
}
#[must_use]
pub fn shift(self) -> MouseChord {
MouseChord::from(self).shift()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct MouseChord {
button: MouseButton,
ctrl: bool,
alt: bool,
shift: bool,
}
impl From<MouseButton> for MouseChord {
fn from(button: MouseButton) -> Self {
Self {
button,
ctrl: false,
alt: false,
shift: false,
}
}
}
impl MouseChord {
#[must_use]
pub fn ctrl(mut self) -> Self {
self.ctrl = true;
self
}
#[must_use]
pub fn alt(mut self) -> Self {
self.alt = true;
self
}
#[must_use]
pub fn shift(mut self) -> Self {
self.shift = true;
self
}
fn code(self) -> u8 {
self.button.code()
+ 4 * u8::from(self.shift)
+ 8 * u8::from(self.alt)
+ 16 * u8::from(self.ctrl)
}
}
fn no_mouse_tracking() -> Error {
Error::Input(
"the application has not enabled mouse tracking \
(no CSI ?9/?1000/?1002/?1003 h was seen)"
.into(),
)
}
#[cfg(unix)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Signal {
Int,
Term,
Hup,
Quit,
Usr1,
Usr2,
Kill,
}
#[cfg(unix)]
impl Signal {
fn raw(self) -> libc::c_int {
match self {
Signal::Int => libc::SIGINT,
Signal::Term => libc::SIGTERM,
Signal::Hup => libc::SIGHUP,
Signal::Quit => libc::SIGQUIT,
Signal::Usr1 => libc::SIGUSR1,
Signal::Usr2 => libc::SIGUSR2,
Signal::Kill => libc::SIGKILL,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExitStatus {
code: Option<u32>,
success: bool,
signal: Option<Box<str>>,
}
impl ExitStatus {
fn from_pty(status: &portable_pty::ExitStatus) -> Self {
let signal = status.signal().map(Box::<str>::from);
Self {
code: signal.is_none().then(|| status.exit_code()),
success: status.success(),
signal,
}
}
#[must_use]
pub fn success(&self) -> bool {
self.success
}
#[must_use]
pub fn code(&self) -> Option<u32> {
self.code
}
#[must_use]
pub fn signal(&self) -> Option<&str> {
self.signal.as_deref()
}
}
impl fmt::Display for ExitStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match (&self.signal, self.code) {
(Some(signal), _) => write!(f, "killed by signal: {signal}"),
(None, Some(code)) => write!(f, "exit code {code}"),
(None, None) => write!(f, "exited with no status reported"),
}
}
}
struct Responder {
respond: bool,
background: (u8, u8, u8),
foreground: (u8, u8, u8),
cell_size: Option<(u16, u16)>,
graphics: Graphics,
term_name: String,
}
struct EmuState {
emu: Box<dyn Emulator>,
last_activity: Instant,
eof: bool,
generation: u64,
snapshot_cache: Option<(u64, Screen)>,
frames_seen: u64,
frames: VecDeque<(u64, Screen)>,
timings: VecDeque<FrameTiming>,
respond: bool,
background: (u8, u8, u8),
foreground: (u8, u8, u8),
cell_size: Option<(u16, u16)>,
graphics: Graphics,
term_name: String,
unanswered: Vec<(String, u64)>,
unanswered_overflow: usize,
replies_dropped: usize,
replies_pending: Arc<AtomicUsize>,
reads: u64,
}
impl EmuState {
fn new(
emu: Box<dyn Emulator>,
responder: Responder,
replies_pending: Arc<AtomicUsize>,
) -> Self {
let Responder {
respond,
background,
foreground,
cell_size,
graphics,
term_name,
} = responder;
Self {
emu,
last_activity: Instant::now(),
eof: false,
generation: 0,
snapshot_cache: None,
frames_seen: 0,
frames: VecDeque::with_capacity(FRAME_HISTORY),
timings: VecDeque::new(),
respond,
background,
foreground,
cell_size,
graphics,
term_name,
unanswered: Vec::new(),
unanswered_overflow: 0,
replies_dropped: 0,
replies_pending,
reads: 0,
}
}
fn note_unanswered(&mut self, shape: String) {
let reads = self.reads;
if let Some(entry) = self.unanswered.iter_mut().find(|(seen, _)| *seen == shape) {
entry.1 = reads;
} else if self.unanswered.len() < MAX_UNANSWERED {
self.unanswered.push((shape, reads));
} else {
self.unanswered_overflow += 1;
}
}
fn answer(&mut self, query: &Query) -> Option<Vec<u8>> {
fn osc_color(code: u8, (r, g, b): (u8, u8, u8), st: bool) -> Vec<u8> {
let widen = |v: u8| u16::from(v) << 8 | u16::from(v);
let terminator = if st { "\x1b\\" } else { "\x07" };
format!(
"\x1b]{code};rgb:{:04x}/{:04x}/{:04x}{terminator}",
widen(r),
widen(g),
widen(b)
)
.into_bytes()
}
if !self.respond {
self.note_unanswered(query_shape(query));
return None;
}
let reply = match query {
Query::CursorPosition { private } => {
let (row, col, _) = self.emu.snapshot().cursor();
let prefix = if *private { "?" } else { "" };
format!("\x1b[{prefix}{};{}R", row + 1, col + 1).into_bytes()
}
Query::OperatingStatus => b"\x1b[0n".to_vec(),
Query::PrimaryDa => match self.graphics {
Graphics::Sixel => b"\x1b[?62;4;22c".to_vec(),
_ => b"\x1b[?62;22c".to_vec(),
},
Query::SecondaryDa => b"\x1b[>1;10;0c".to_vec(),
Query::TextAreaSize => {
let screen = self.emu.snapshot();
format!("\x1b[8;{};{}t", screen.rows(), screen.cols()).into_bytes()
}
Query::CellSizePixels => match self.cell_size {
Some((w, h)) => format!("\x1b[6;{h};{w}t").into_bytes(),
None => {
self.note_unanswered(query_shape(query));
return None;
}
},
Query::WindowSizePixels => match self.cell_size {
Some((w, h)) => {
let screen = self.emu.snapshot();
let height = u32::from(screen.rows()) * u32::from(h);
let width = u32::from(screen.cols()) * u32::from(w);
format!("\x1b[4;{height};{width}t").into_bytes()
}
None => {
self.note_unanswered(query_shape(query));
return None;
}
},
Query::KittyGraphics { id, shape } => {
if self.graphics == Graphics::Kitty {
let id = id.unwrap_or(0);
format!("\x1b_Gi={id};OK\x1b\\").into_bytes()
} else {
self.note_unanswered(shape.clone());
return None;
}
}
Query::OscColor {
code: 11,
st_terminated,
} => osc_color(11, self.background, *st_terminated),
Query::OscColor {
code,
st_terminated,
} => osc_color(*code, self.foreground, *st_terminated),
Query::RequestMode(mode) => {
let value = self.emu.mode_state(*mode).report_value();
format!("\x1b[?{mode};{value}$y").into_bytes()
}
Query::RequestTermcap { names, shape } => {
match termcap_reply(names, &self.term_name) {
Some(reply) => reply,
None => {
self.note_unanswered(shape.clone());
return None;
}
}
}
Query::Unanswerable(shape) => {
self.note_unanswered(shape.clone());
return None;
}
};
Some(reply)
}
fn touch(&mut self) {
self.last_activity = Instant::now();
self.generation += 1;
}
fn snapshot(&mut self) -> Screen {
if let Some((generation, screen)) = &self.snapshot_cache {
if *generation == self.generation {
return screen.clone();
}
}
let screen = self.build_snapshot();
self.snapshot_cache = Some((self.generation, screen.clone()));
screen
}
fn build_snapshot(&self) -> Screen {
self.emu.snapshot().with_repaints(self.frames_seen)
}
fn query_note(&self) -> String {
let stuck = self.replies_pending.load(Ordering::Relaxed);
let undelivered = self.replies_dropped + stuck;
let backlog = if undelivered > 0 {
format!(
" — note: the application is not reading its input \
({undelivered} terminal replies could not be delivered)"
)
} else {
String::new()
};
if self.unanswered.is_empty() {
return backlog;
}
let mut blocking: Vec<&str> = Vec::new();
let mut moved_past: Vec<&str> = Vec::new();
for (shape, seen_at) in &self.unanswered {
if *seen_at == self.reads {
blocking.push(shape);
} else {
moved_past.push(shape);
}
}
let more = if self.unanswered_overflow > 0 {
format!(", and {} more", self.unanswered_overflow)
} else {
String::new()
};
if blocking.is_empty() {
format!(
"{backlog} — note: the application queried the terminal \
({}{more}) and received no answer, but produced output \
afterwards, so that is probably not why this wait failed",
moved_past.join(", ")
)
} else {
format!(
"{backlog} — note: the application queried the terminal \
({}{more}) and received no answer; if it is blocked waiting \
for that reply, this is the cause",
blocking.join(", ")
)
}
}
fn peek_snapshot(&self) -> Screen {
if let Some((generation, screen)) = &self.snapshot_cache {
if *generation == self.generation {
return screen.clone();
}
}
self.build_snapshot()
}
}
#[derive(Debug, Clone)]
pub struct TerminalBuilder {
cols: u16,
rows: u16,
timeout: Duration,
args: Vec<OsString>,
env_clear: bool,
envs: Vec<(OsString, OsString)>,
cwd: Option<PathBuf>,
answer_queries: bool,
background: (u8, u8, u8),
foreground: (u8, u8, u8),
scrollback: usize,
cell_size: Option<(u16, u16)>,
graphics: Graphics,
capture_graphics: usize,
}
impl Default for TerminalBuilder {
fn default() -> Self {
Self {
cols: 80,
rows: 24,
timeout: Duration::from_secs(5),
args: Vec::new(),
env_clear: false,
envs: Vec::new(),
cwd: None,
answer_queries: true,
background: (0, 0, 0),
foreground: (0xff, 0xff, 0xff),
scrollback: DEFAULT_SCROLLBACK,
cell_size: None,
graphics: Graphics::None,
capture_graphics: DEFAULT_CAPTURE,
}
}
}
impl TerminalBuilder {
#[must_use]
pub fn size(mut self, cols: u16, rows: u16) -> Self {
self.cols = cols;
self.rows = rows;
self
}
#[must_use]
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
#[must_use]
pub fn arg(mut self, arg: impl AsRef<OsStr>) -> Self {
self.args.push(arg.as_ref().to_os_string());
self
}
#[must_use]
pub fn args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
self.args
.extend(args.into_iter().map(|a| a.as_ref().to_os_string()));
self
}
#[must_use]
pub fn env(mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> Self {
self.envs
.push((key.as_ref().to_os_string(), value.as_ref().to_os_string()));
self
}
#[must_use]
pub fn envs<I, K, V>(mut self, vars: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: AsRef<OsStr>,
V: AsRef<OsStr>,
{
self.envs.extend(
vars.into_iter()
.map(|(key, value)| (key.as_ref().to_os_string(), value.as_ref().to_os_string())),
);
self
}
#[must_use]
pub fn env_clear(mut self) -> Self {
self.env_clear = true;
self
}
#[must_use]
pub fn current_dir(mut self, dir: impl AsRef<Path>) -> Self {
self.cwd = Some(dir.as_ref().to_path_buf());
self
}
#[must_use]
pub fn scrollback(mut self, rows: usize) -> Self {
self.scrollback = rows;
self
}
#[must_use]
pub fn capture_graphics(mut self, bytes: usize) -> Self {
self.capture_graphics = bytes;
self
}
#[must_use]
pub fn answer_queries(mut self, answer: bool) -> Self {
self.answer_queries = answer;
self
}
#[must_use]
pub fn background_rgb(mut self, r: u8, g: u8, b: u8) -> Self {
self.background = (r, g, b);
self
}
#[must_use]
pub fn foreground_rgb(mut self, r: u8, g: u8, b: u8) -> Self {
self.foreground = (r, g, b);
self
}
#[must_use]
pub fn cell_size(mut self, width: u16, height: u16) -> Self {
self.cell_size = Some((width, height));
self
}
#[must_use]
pub fn graphics(mut self, graphics: Graphics) -> Self {
self.graphics = graphics;
self
}
fn validate(&self, command_desc: &str, program: &OsStr) -> Result<()> {
let spawn_err = |reason: String| {
Err(Error::Spawn {
command: command_desc.to_owned(),
reason,
})
};
if program.is_empty() {
return spawn_err("no program name given (the program argument was empty)".into());
}
check_size(self.cols, self.rows)?;
if let Some(dir) = &self.cwd {
if !dir.is_dir() {
return spawn_err(format!(
"current_dir({}) is not an existing directory",
dir.display()
));
}
}
Ok(())
}
pub fn spawn(self, program: impl AsRef<OsStr>) -> Result<Terminal> {
let program = program.as_ref();
let command_desc = std::iter::once(program)
.chain(self.args.iter().map(OsString::as_os_str))
.map(|s| s.to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join(" ");
self.validate(&command_desc, program)?;
let (pair, lifecycle) = open_pty(PtySize {
rows: self.rows,
cols: self.cols,
pixel_width: pixel_span(self.cols, self.cell_size.map(|(w, _)| w)),
pixel_height: pixel_span(self.rows, self.cell_size.map(|(_, h)| h)),
})?;
let mut cmd = CommandBuilder::new(program);
cmd.args(&self.args);
if let Some(dir) = &self.cwd {
cmd.cwd(dir);
}
if self.env_clear {
cmd.env_clear();
}
let term_name = self.envs.iter().find(|(k, _)| k == "TERM").map_or_else(
|| "xterm-256color".to_owned(),
|(_, v)| v.to_string_lossy().into_owned(),
);
if !self.envs.iter().any(|(k, _)| k == "TERM") {
cmd.env("TERM", "xterm-256color");
}
for (key, value) in &self.envs {
cmd.env(key, value);
}
let reader = pair
.master
.try_clone_reader()
.map_err(|e| Error::Pty(format!("cloning PTY reader failed: {e}")))?;
let writer = pair
.master
.take_writer()
.map_err(|e| Error::Pty(format!("taking PTY writer failed: {e}")))?;
let replies_pending = Arc::new(AtomicUsize::new(0));
let queued_bytes = Arc::new(AtomicUsize::new(0));
let shared = Arc::new(Monitor::new(EmuState::new(
Box::new(Vt100Emulator::new(
self.rows,
self.cols,
self.scrollback,
self.capture_graphics,
)),
Responder {
respond: self.answer_queries,
background: self.background,
foreground: self.foreground,
cell_size: self.cell_size,
graphics: self.graphics,
term_name,
},
Arc::clone(&replies_pending),
)));
let writer: SharedWriter = Arc::new(Mutex::new(Some(writer)));
let write_tx = match dup_writer(pair.master.as_ref()) {
Some(mut pty_writer) => {
let (tx, rx) = mpsc::channel::<WriteRequest>();
let written = Arc::clone(&replies_pending);
let drained = Arc::clone(&queued_bytes);
thread::Builder::new()
.name("termlens-pty-writer".into())
.spawn(move || {
while let Ok(request) = rx.recv() {
let mut bytes = request.bytes;
let mut acks: Vec<_> = request.ack.into_iter().collect();
let mut replies = request.replies;
while let Ok(next) = rx.try_recv() {
bytes.extend_from_slice(&next.bytes);
acks.extend(next.ack);
replies += next.replies;
}
let queued = bytes.len();
let result = pty_writer
.write_all(&bytes)
.and_then(|()| pty_writer.flush());
written.fetch_sub(replies, Ordering::Relaxed);
drained.fetch_sub(
queued.min(drained.load(Ordering::Relaxed)),
Ordering::Relaxed,
);
let failed = result.is_err();
for ack in acks {
let _ = ack.send(match &result {
Ok(()) => Ok(()),
Err(e) => Err(io::Error::new(e.kind(), e.to_string())),
});
}
if failed {
break; }
}
})
.map_err(Error::Io)?;
Some(tx)
}
None => None,
};
let reader_shared = Arc::clone(&shared);
let reader_writer = Arc::clone(&writer);
let reader_tx = write_tx.clone();
let reader_pending = Arc::clone(&replies_pending);
let reader_queued = Arc::clone(&queued_bytes);
thread::Builder::new()
.name("termlens-pty-reader".into())
.spawn(move || {
reader_loop(
reader,
&reader_shared,
&reader_writer,
reader_tx.as_ref(),
&reader_pending,
&reader_queued,
)
})
.map_err(Error::Io)?;
let child = pair.slave.spawn_command(cmd).map_err(|e| Error::Spawn {
command: command_desc.clone(),
reason: e.to_string(),
})?;
drop(pair.slave);
drop(lifecycle);
Ok(Terminal {
child,
master: Some(pair.master),
writer,
write_tx,
shared,
default_timeout: self.timeout,
exit_status: None,
command_desc,
frame_cursor: 0,
cell_size: self.cell_size,
})
}
}
fn strip_paste_markers(text: &str) -> String {
let mut out = text.to_owned();
loop {
let stripped = out.replace("\x1b[200~", "").replace("\x1b[201~", "");
if stripped == out {
return out;
}
out = stripped;
}
}
const MAX_DIMENSION: u16 = 1000;
fn termcap_value(name: &str, term_name: &str) -> Option<String> {
Some(match name {
"TN" | "name" => term_name.to_owned(),
"Co" | "colors" => "256".to_owned(),
"kbs" => "\u{7f}".to_owned(),
"kcuu1" => "\u{1b}[A".to_owned(),
"kcud1" => "\u{1b}[B".to_owned(),
"kcuf1" => "\u{1b}[C".to_owned(),
"kcub1" => "\u{1b}[D".to_owned(),
"khome" => "\u{1b}[H".to_owned(),
"kend" => "\u{1b}[F".to_owned(),
"kdch1" => "\u{1b}[3~".to_owned(),
"kpp" => "\u{1b}[5~".to_owned(),
"knp" => "\u{1b}[6~".to_owned(),
_ => return None,
})
}
fn termcap_reply(names: &str, term_name: &str) -> Option<Vec<u8>> {
let mut out = Vec::new();
let mut decoded_any = false;
for hex_name in names.split(';').filter(|s| !s.is_empty()) {
let Some(name) = decode_hex(hex_name) else {
continue;
};
decoded_any = true;
match termcap_value(&name, term_name) {
Some(value) => out.extend_from_slice(
format!("\x1bP1+r{hex_name}={}\x1b\\", encode_hex(value.as_bytes())).as_bytes(),
),
None => {
out.extend_from_slice(format!("\x1bP0+r{hex_name}\x1b\\").as_bytes());
}
}
}
decoded_any.then_some(out)
}
fn decode_hex(hex: &str) -> Option<String> {
if hex.len() % 2 != 0 || hex.is_empty() {
return None;
}
let bytes: Option<Vec<u8>> = (0..hex.len() / 2)
.map(|i| u8::from_str_radix(hex.get(i * 2..i * 2 + 2)?, 16).ok())
.collect();
String::from_utf8(bytes?).ok()
}
fn encode_hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn cells_between(from: (u16, u16), to: (u16, u16)) -> Vec<(u16, u16)> {
let (from_col, from_row) = (i32::from(from.0), i32::from(from.1));
let (to_col, to_row) = (i32::from(to.0), i32::from(to.1));
let d_col = to_col - from_col;
let d_row = to_row - from_row;
let steps = d_col.abs().max(d_row.abs());
if steps == 0 {
return vec![to];
}
(1..=steps)
.map(|step| {
let col = from_col + (d_col * step + d_col.signum() * steps / 2) / steps;
let row = from_row + (d_row * step + d_row.signum() * steps / 2) / steps;
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
(col as u16, row as u16)
})
.collect()
}
fn pixel_span(cells: u16, per_cell: Option<u16>) -> u16 {
per_cell.map_or(0, |px| cells.saturating_mul(px))
}
fn check_size(cols: u16, rows: u16) -> Result<()> {
if cols == 0 || rows == 0 {
return Err(Error::Input(format!(
"a terminal needs at least one column and one row, got {cols}x{rows} \
(columns x rows)"
)));
}
if cols > MAX_DIMENSION || rows > MAX_DIMENSION {
return Err(Error::Input(format!(
"{cols}x{rows} (columns x rows) is past the {MAX_DIMENSION}-per-axis \
limit; a snapshot costs one entry per cell, so a grid this large \
makes every wait slow enough to look like a hang"
)));
}
Ok(())
}
fn frames_phrase(n: u64) -> String {
if n == 1 {
"1 complete frame".to_owned()
} else {
format!("{n} complete frames")
}
}
fn query_shape(query: &Query) -> String {
match query {
Query::CursorPosition { private: false } => "^[[6n".into(),
Query::CursorPosition { private: true } => "^[[?6n".into(),
Query::OperatingStatus => "^[[5n".into(),
Query::PrimaryDa => "^[[c".into(),
Query::SecondaryDa => "^[[>c".into(),
Query::TextAreaSize => "^[[18t".into(),
Query::WindowSizePixels => "^[[14t".into(),
Query::CellSizePixels => "^[[16t".into(),
Query::KittyGraphics { shape, .. } => shape.clone(),
Query::RequestTermcap { shape, .. } => shape.clone(),
Query::OscColor { code, .. } => format!("^[]{code};?"),
Query::RequestMode(mode) => format!("^[[?{mode}$p"),
Query::Unanswerable(shape) => shape.clone(),
}
}
fn reader_loop(
mut reader: Box<dyn Read + Send>,
shared: &Monitor<EmuState>,
writer: &SharedWriter,
replies_to: Option<&mpsc::Sender<WriteRequest>>,
pending: &AtomicUsize,
queued_bytes: &AtomicUsize,
) {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
let replies = shared.mutate(|state| {
state.reads += 1;
let mut replies: Vec<Vec<u8>> = Vec::new();
let mut offset = 0;
while offset < n {
let processed = state.emu.process(&buf[offset..n]);
offset += processed.consumed;
match processed.stop {
Some(Stop::FrameComplete(span)) => {
state.frames_seen += 1;
let frame = state.build_snapshot();
if state.frames.len() == FRAME_HISTORY {
state.frames.pop_front();
}
state.frames.push_back((state.frames_seen, frame));
if state.timings.len() == FRAME_TIMING_HISTORY {
state.timings.pop_front();
}
state.timings.push_back(FrameTiming {
index: state.frames_seen,
duration: span.duration,
printable: span.printable,
});
}
Some(Stop::Query(query)) => {
if let Some(reply) = state.answer(&query) {
replies.push(reply);
}
}
None => {}
}
}
state.touch();
replies
});
if !replies.is_empty() {
let batch: Vec<u8> = replies.concat();
let count = replies.len();
match replies_to {
Some(tx) => {
let size = batch.len();
if queued_bytes.load(Ordering::Relaxed) + size > REPLY_QUEUE_BYTES {
shared.mutate(|state| state.replies_dropped += count);
} else {
pending.fetch_add(count, Ordering::Relaxed);
queued_bytes.fetch_add(size, Ordering::Relaxed);
let request = WriteRequest {
bytes: batch,
ack: None, replies: count,
};
if tx.send(request).is_err() {
pending.fetch_sub(count, Ordering::Relaxed);
queued_bytes.fetch_sub(size, Ordering::Relaxed);
}
}
}
None => {
let mut writer = writer.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(writer) = writer.as_mut() {
let _ = writer.write_all(&batch).and_then(|()| writer.flush());
}
}
}
}
}
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
Err(_) => break,
}
}
shared.mutate(|state| {
state.eof = true;
state.touch();
});
}
pub struct Terminal {
child: Box<dyn portable_pty::Child + Send + Sync>,
master: Option<Box<dyn portable_pty::MasterPty + Send>>,
writer: SharedWriter,
write_tx: Option<mpsc::Sender<WriteRequest>>,
shared: Arc<Monitor<EmuState>>,
default_timeout: Duration,
exit_status: Option<ExitStatus>,
command_desc: String,
frame_cursor: u64,
cell_size: Option<(u16, u16)>,
}
impl Terminal {
#[must_use]
pub fn builder() -> TerminalBuilder {
TerminalBuilder::default()
}
#[must_use]
pub fn screen(&self) -> Screen {
self.shared.lock().snapshot()
}
#[must_use]
pub fn frame_timings(&self) -> Vec<FrameTiming> {
self.shared.lock().timings.iter().copied().collect()
}
pub fn send(&mut self, key: impl Input + fmt::Debug) -> Result<()> {
let what = format!("{key:?}");
self.ensure_deliverable(&what)?;
let application_cursor = self.input_modes().application_cursor;
self.write_input(&key.encode_modal(application_cursor), &what)
}
pub fn send_after(&mut self, delay: Duration, key: impl Input + fmt::Debug) -> Result<()> {
thread::sleep(delay);
self.send(key)
}
pub fn send_str(&mut self, s: &str) -> Result<()> {
self.ensure_deliverable("literal text")?;
self.write_input(s.as_bytes(), "literal text")
}
pub fn paste(&mut self, text: &str) -> Result<()> {
self.ensure_deliverable("a paste")?;
let text = text.replace("\r\n", "\r").replace('\n', "\r");
if self.input_modes().bracketed_paste {
let mut bytes = b"\x1b[200~".to_vec();
bytes.extend_from_slice(strip_paste_markers(&text).as_bytes());
bytes.extend_from_slice(b"\x1b[201~");
self.write_input(&bytes, "a bracketed paste")
} else {
self.write_input(text.as_bytes(), "a paste")
}
}
pub fn focus_in(&mut self) -> Result<()> {
self.focus(true)
}
pub fn focus_out(&mut self) -> Result<()> {
self.focus(false)
}
fn focus(&mut self, gained: bool) -> Result<()> {
let what = if gained { "focus-in" } else { "focus-out" };
self.ensure_deliverable(what)?;
if !self.input_modes().focus_events {
return Err(Error::Input(
"the application has not enabled focus reporting \
(no CSI ?1004 h was seen)"
.into(),
));
}
let bytes: &[u8] = if gained { b"\x1b[I" } else { b"\x1b[O" };
self.write_input(bytes, what)
}
pub fn click(&mut self, col: u16, row: u16) -> Result<()> {
self.click_with(MouseButton::Left, col, row)
}
pub fn click_with(&mut self, button: impl Into<MouseChord>, col: u16, row: u16) -> Result<()> {
self.ensure_deliverable("a mouse click")?;
let chord = button.into();
let modes = self.input_modes();
let press_only = match modes.mouse {
MouseMode::None => return Err(no_mouse_tracking()),
MouseMode::Press => true,
MouseMode::PressRelease | MouseMode::ButtonMotion | MouseMode::AnyMotion => false,
};
let mut bytes = self.mouse_report(&modes, chord.code(), col, row, true)?;
if !press_only {
bytes.extend(self.mouse_report(&modes, chord.code(), col, row, false)?);
}
self.write_input(&bytes, "a mouse click")
}
pub fn drag(
&mut self,
button: impl Into<MouseChord>,
from: (u16, u16),
to: (u16, u16),
) -> Result<()> {
self.ensure_deliverable("a mouse drag")?;
let chord = button.into();
let modes = self.input_modes();
let report_motion = match modes.mouse {
MouseMode::None => return Err(no_mouse_tracking()),
MouseMode::Press => {
return Err(Error::Input(
"the application enabled X10 mouse tracking (CSI ?9 h), which \
reports presses only — a drag has no release to report"
.into(),
))
}
MouseMode::PressRelease => false,
MouseMode::ButtonMotion | MouseMode::AnyMotion => true,
};
let mut bytes = self.mouse_report(&modes, chord.code(), from.0, from.1, true)?;
if report_motion {
for (col, row) in cells_between(from, to) {
bytes.extend(self.mouse_report(&modes, chord.code() + 32, col, row, true)?);
}
}
bytes.extend(self.mouse_report(&modes, chord.code(), to.0, to.1, false)?);
self.write_input(&bytes, "a mouse drag")
}
pub fn scroll(&mut self, col: u16, row: u16, direction: Scroll) -> Result<()> {
self.ensure_deliverable("a mouse scroll")?;
let modes = self.input_modes();
if modes.mouse == MouseMode::None {
return Err(no_mouse_tracking());
}
let button = match direction {
Scroll::Up => 64,
Scroll::Down => 65,
Scroll::Left => 66,
Scroll::Right => 67,
};
let bytes = self.mouse_report(&modes, button, col, row, true)?;
self.write_input(&bytes, "a mouse scroll")
}
fn input_modes(&self) -> InputModes {
self.shared.lock().emu.input_modes()
}
fn mouse_report(
&self,
modes: &InputModes,
button: u8,
col: u16,
row: u16,
press: bool,
) -> Result<Vec<u8>> {
if modes.mouse_encoding == MouseEncoding::Sgr {
return Ok(mouse_sgr(button, col, row, press));
}
if col > 222 || row > 222 {
return Err(Error::Input(format!(
"({col}, {row}) is unrepresentable in the legacy mouse \
encoding the application selected (max 222)"
)));
}
let button = if press { button } else { 3 }; Ok(match modes.mouse_encoding {
MouseEncoding::Utf8 => mouse_utf8(button, col, row),
MouseEncoding::Legacy | MouseEncoding::Sgr => mouse_legacy(button, col, row),
})
}
fn write_input(&mut self, bytes: &[u8], what: &str) -> Result<()> {
match self.write_within_deadline(bytes) {
Ok(()) => Ok(()),
Err(reason) => Err(Error::Write {
what: format!("{what} to `{}` ({reason})", self.command_desc).into(),
screen: self.screen(),
}),
}
}
fn ensure_deliverable(&mut self, what: &str) -> Result<()> {
if !self.shared.lock().eof {
return Ok(());
}
let reason = match &self.exit_status {
Some(status) => format!("the child is gone ({status}) and the terminal is closed"),
None => "the child released the terminal (EOF), so nothing can read this".to_owned(),
};
Err(Error::Write {
what: format!("{what} to `{}` ({reason})", self.command_desc).into(),
screen: self.screen(),
})
}
fn write_within_deadline(&mut self, bytes: &[u8]) -> std::result::Result<(), String> {
let Some(tx) = &self.write_tx else {
let mut writer = self.writer.lock().unwrap_or_else(PoisonError::into_inner);
return match writer.as_mut() {
Some(writer) => writer
.write_all(bytes)
.and_then(|()| writer.flush())
.map_err(|e| e.to_string()),
None => Err("the terminal is closed".to_owned()),
};
};
let not_reading = || {
format!(
"the application is not reading its input, and the PTY buffer is \
full — no progress in {:?}",
self.default_timeout
)
};
let (ack_tx, ack_rx) = mpsc::sync_channel(1);
let request = WriteRequest {
bytes: bytes.to_vec(),
ack: Some(ack_tx),
replies: 0,
};
if tx.send(request).is_err() {
return Err("the terminal is closed".to_owned());
}
match ack_rx.recv_timeout(self.default_timeout) {
Ok(Ok(())) => Ok(()),
Ok(Err(e)) => Err(e.to_string()),
Err(mpsc::RecvTimeoutError::Timeout) => Err(not_reading()),
Err(mpsc::RecvTimeoutError::Disconnected) => Err("the terminal is closed".to_owned()),
}
}
pub fn wait_until(&mut self, predicate: impl FnMut(&Screen) -> bool) -> Result<()> {
self.wait_until_deadline(predicate, self.default_timeout)
}
pub fn wait_until_for(
&mut self,
predicate: impl FnMut(&Screen) -> bool,
timeout: Duration,
) -> Result<()> {
self.wait_until_deadline(predicate, timeout)
}
fn wait_until_deadline(
&mut self,
mut predicate: impl FnMut(&Screen) -> bool,
timeout: Duration,
) -> Result<()> {
const WHAT: &str = "the screen predicate to hold";
let deadline = Instant::now() + timeout;
let mut seen_generation = None;
let outcome = self.shared.wait_until(deadline, |state| {
if seen_generation == Some(state.generation) {
return None;
}
seen_generation = Some(state.generation);
let screen = state.peek_snapshot();
if predicate(&screen) {
return Some(Ok(()));
}
if state.eof {
return Some(Err(Error::Eof {
waiting_for: format!("{WHAT}{}", state.query_note()),
screen,
}));
}
None
});
match outcome {
Ok(inner) => inner,
Err(Expired) => Err(Error::Timeout {
waiting_for: format!("{WHAT}{}", self.shared.lock().query_note()),
timeout,
screen: self.screen(),
}),
}
}
pub fn wait_frame(&mut self, predicate: impl FnMut(&Screen) -> bool) -> Result<Screen> {
self.wait_frame_deadline(predicate, self.default_timeout)
}
pub fn wait_frame_for(
&mut self,
predicate: impl FnMut(&Screen) -> bool,
timeout: Duration,
) -> Result<Screen> {
self.wait_frame_deadline(predicate, timeout)
}
fn wait_frame_deadline(
&mut self,
mut predicate: impl FnMut(&Screen) -> bool,
timeout: Duration,
) -> Result<Screen> {
const WHAT: &str = "a complete frame matching the predicate";
let deadline = Instant::now() + timeout;
let cursor = self.frame_cursor;
let mut seen_frame = None;
let outcome = self.shared.wait_until(deadline, |state| {
if state.frames_seen > cursor && seen_frame != Some(state.frames_seen) {
seen_frame = Some(state.frames_seen);
let matched = state
.frames
.iter()
.find(|(index, frame)| *index > cursor && predicate(frame));
if let Some((index, frame)) = matched {
return Some(Ok((*index, frame.clone())));
}
}
if state.eof {
return Some(Err(Error::Eof {
waiting_for: format!("{WHAT}{}", state.query_note()),
screen: state.peek_snapshot(),
}));
}
None
});
match outcome {
Ok(Ok((index, frame))) => {
self.frame_cursor = index;
Ok(frame)
}
Ok(Err(e)) => Err(e),
Err(Expired) => {
let (frames, screen, note) = {
let mut guard = self.shared.lock();
let screen = guard.snapshot();
let note = guard.query_note();
(guard.frames_seen, screen, note)
};
let waiting_for = if frames > 0 && frames == cursor {
format!(
"{WHAT} — the application has not completed a repaint since the \
frame this terminal last returned ({} in total). If it does not \
repaint in response to this input, assert on the screen with \
wait_until instead{note}",
frames_phrase(frames)
)
} else if frames == 0 {
format!(
"a complete frame — but the application never emitted a \
DEC 2026 synchronized update. wait_frame needs repaints \
bracketed in BeginSynchronizedUpdate/EndSynchronizedUpdate; \
for other apps use wait_until (docs/DESIGN.md §2){note}"
)
} else if cursor == 0 {
format!("{WHAT} ({} observed){note}", frames_phrase(frames))
} else {
format!(
"{WHAT} ({} since the last one returned, {frames} in total){note}",
frames_phrase(frames - cursor)
)
};
Err(Error::Timeout {
waiting_for,
timeout,
screen,
})
}
}
}
pub fn wait_idle(&mut self, quiet: Duration) -> Result<()> {
self.wait_idle_deadline(quiet, self.default_timeout)
}
pub fn wait_idle_for(&mut self, quiet: Duration, timeout: Duration) -> Result<()> {
self.wait_idle_deadline(quiet, timeout)
}
fn wait_idle_deadline(&mut self, quiet: Duration, timeout: Duration) -> Result<()> {
let deadline = Instant::now() + timeout;
let mut guard = self.shared.lock();
loop {
if guard.eof {
return Ok(());
}
let elapsed = guard.last_activity.elapsed();
if elapsed >= quiet && !guard.emu.mid_sequence() && !guard.emu.in_sync_update() {
return Ok(());
}
let now = Instant::now();
if now >= deadline {
let stuck_mid_frame = guard.emu.in_sync_update();
let screen = guard.peek_snapshot();
let note = guard.query_note();
drop(guard);
let waiting_for = if stuck_mid_frame {
format!(
"{quiet:?} of output silence — the application is inside an \
unfinished DEC 2026 synchronized update (Begin with no End), so \
the screen below is a half-painted frame{note}"
)
} else {
format!("{quiet:?} of output silence{note}")
};
return Err(Error::Timeout {
waiting_for,
timeout,
screen,
});
}
let sleep = if elapsed < quiet {
quiet - elapsed
} else {
POLL_CAP
}
.min(deadline - now)
.max(Duration::from_millis(1));
guard = self.shared.wait_timeout(guard, sleep);
}
}
#[must_use]
pub fn pid(&self) -> Option<u32> {
self.child.process_id()
}
#[cfg(unix)]
pub fn signal(&mut self, signal: Signal) -> Result<()> {
if let Some(status) = &self.exit_status {
return Err(Error::Input(format!(
"cannot deliver {signal:?} to `{}`: it already exited ({status})",
self.command_desc
)));
}
let Some(pid) = self.pid() else {
return Err(Error::Input(format!(
"cannot deliver {signal:?} to `{}`: the platform reports no pid",
self.command_desc
)));
};
let pid = libc::pid_t::try_from(pid)
.map_err(|_| Error::Input(format!("pid {pid} exceeds the platform's pid range")))?;
#[allow(unsafe_code)]
let rc = unsafe { libc::kill(pid, signal.raw()) };
if rc == 0 {
Ok(())
} else {
Err(Error::Io(io::Error::last_os_error()))
}
}
pub fn wait_exit(&mut self) -> Result<ExitStatus> {
self.wait_exit_deadline(self.default_timeout)
}
pub fn wait_exit_for(&mut self, timeout: Duration) -> Result<ExitStatus> {
self.wait_exit_deadline(timeout)
}
fn wait_exit_deadline(&mut self, timeout: Duration) -> Result<ExitStatus> {
if let Some(status) = self.exit_status.clone() {
return Ok(status);
}
let deadline = Instant::now() + timeout;
let mut backoff = INITIAL_BACKOFF;
loop {
if let Some(status) = self.child.try_wait().map_err(Error::Io)? {
let status = ExitStatus::from_pty(&status);
self.exit_status = Some(status.clone());
let _ = self
.shared
.wait_until(Instant::now() + DRAIN_GRACE, |state| {
state.eof.then_some(())
});
return Ok(status);
}
let now = Instant::now();
if now >= deadline {
return Err(Error::Timeout {
waiting_for: format!(
"`{}` to exit{}",
self.command_desc,
self.shared.lock().query_note()
),
timeout,
screen: self.screen(),
});
}
thread::sleep(backoff.min(deadline - now));
backoff = next_backoff(backoff);
}
}
pub fn resize(&mut self, cols: u16, rows: u16) -> Result<()> {
check_size(cols, rows)?;
self.master
.as_ref()
.expect("master lives until drop")
.resize(PtySize {
rows,
cols,
pixel_width: pixel_span(cols, self.cell_size.map(|(w, _)| w)),
pixel_height: pixel_span(rows, self.cell_size.map(|(_, h)| h)),
})
.map_err(|e| Error::Pty(format!("resize failed: {e}")))?;
self.frame_cursor = self.shared.mutate(|state| {
state.emu.set_size(rows, cols);
state.touch();
state.frames_seen
});
Ok(())
}
}
impl fmt::Debug for Terminal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Terminal")
.field("command", &self.command_desc)
.field("default_timeout", &self.default_timeout)
.field("exit_status", &self.exit_status)
.finish_non_exhaustive()
}
}
impl Drop for Terminal {
fn drop(&mut self) {
let _lifecycle = pty_lifecycle_guard();
if self.exit_status.is_none() {
let already_exited = matches!(self.child.try_wait(), Ok(Some(_)));
if !already_exited {
let _ = self.child.kill();
let deadline = Instant::now() + DROP_REAP_GRACE;
let mut backoff = INITIAL_BACKOFF;
while !matches!(self.child.try_wait(), Ok(Some(_))) {
let now = Instant::now();
if now >= deadline {
break;
}
thread::sleep(backoff.min(deadline - now));
backoff = next_backoff(backoff);
}
}
}
drop(
self.writer
.lock()
.unwrap_or_else(PoisonError::into_inner)
.take(),
);
drop(self.master.take());
}
}
#[cfg(test)]
mod tests {
use super::cells_between;
#[test]
fn a_horizontal_drag_visits_every_column() {
assert_eq!(
cells_between((5, 4), (9, 4)),
vec![(6, 4), (7, 4), (8, 4), (9, 4)]
);
}
#[test]
fn a_backwards_drag_steps_backwards() {
assert_eq!(cells_between((9, 4), (6, 4)), vec![(8, 4), (7, 4), (6, 4)]);
}
#[test]
fn a_vertical_drag_visits_every_row() {
assert_eq!(cells_between((3, 1), (3, 4)), vec![(3, 2), (3, 3), (3, 4)]);
}
#[test]
fn a_diagonal_drag_interpolates_both_axes() {
assert_eq!(
cells_between((0, 0), (4, 2)),
vec![(1, 1), (2, 1), (3, 2), (4, 2)]
);
let path = cells_between((0, 0), (8, 3));
assert_eq!(path.len(), 8);
assert_eq!(path.last(), Some(&(8, 3)));
}
#[test]
fn a_drag_that_goes_nowhere_still_reports_one_motion() {
assert_eq!(cells_between((7, 7), (7, 7)), vec![(7, 7)]);
}
#[test]
fn interpolation_always_lands_on_the_destination() {
for to in [(1u16, 0u16), (0, 1), (37, 5), (5, 37), (200, 199)] {
let path = cells_between((0, 0), to);
assert_eq!(path.last(), Some(&to), "from (0,0) to {to:?}");
assert!(
!path.contains(&(0, 0)),
"the press already reported the origin"
);
}
}
}