use std::io::{self, IsTerminal, Read, Write};
use std::sync::OnceLock;
static ORIG: OnceLock<libc::termios> = OnceLock::new();
pub struct Term {
pending: Vec<u8>,
out: io::BufWriter<io::Stdout>,
}
impl Term {
pub fn enter() -> io::Result<Term> {
if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
return Err(io::Error::new(
io::ErrorKind::Unsupported,
"not a terminal; the TUI needs stdin and stdout on a tty",
));
}
let mut t: libc::termios = unsafe { std::mem::zeroed() };
ok_or_errno(unsafe { libc::tcgetattr(0, &mut t) })?;
let _ = ORIG.set(t);
let mut raw = t;
unsafe { libc::cfmakeraw(&mut raw) };
raw.c_cc[libc::VMIN] = 0;
raw.c_cc[libc::VTIME] = 0;
ok_or_errno(unsafe { libc::tcsetattr(0, libc::TCSANOW, &raw) })?;
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
restore();
prev(info);
}));
on_signal(libc::SIGWINCH, winch);
on_signal(libc::SIGTERM, bail);
on_signal(libc::SIGHUP, bail);
let mut out = io::BufWriter::new(io::stdout());
out.write_all(b"\x1b[?1049h\x1b[?25l\x1b[2J")?;
out.flush()?;
Ok(Term {
pending: Vec::with_capacity(64),
out,
})
}
pub fn size(&self) -> (usize, usize) {
let mut ws: libc::winsize = unsafe { std::mem::zeroed() };
if unsafe { libc::ioctl(1, libc::TIOCGWINSZ as _, &mut ws) } != 0 || ws.ws_col == 0 {
return (80, 24);
}
(ws.ws_col as usize, ws.ws_row as usize)
}
pub fn draw(&mut self, rows: &[String]) -> io::Result<()> {
self.out.write_all(b"\x1b[H")?;
for row in rows {
self.out.write_all(row.as_bytes())?;
self.out.write_all(b"\x1b[K\r\n")?;
}
self.out.write_all(b"\x1b[J")?;
self.out.flush()
}
pub fn key(&mut self, timeout_ms: i32) -> io::Result<Option<Key>> {
loop {
match parse(&mut self.pending) {
Parsed::Key(k) => return Ok(Some(k)),
Parsed::Need => {}
}
let wait = if self.pending.is_empty() {
timeout_ms
} else {
20
};
if !poll_in(wait)? {
return Ok(match self.pending.is_empty() {
true => None,
false => Some(take_one(&mut self.pending)),
});
}
let mut buf = [0u8; 256];
let n = io::stdin().read(&mut buf)?;
if n == 0 {
return Ok(Some(Key::Ctrl('c')));
}
self.pending.extend_from_slice(&buf[..n]);
}
}
}
enum Parsed {
Key(Key),
Need,
}
fn parse(b: &mut Vec<u8>) -> Parsed {
let Some(&first) = b.first() else {
return Parsed::Need;
};
let one = |b: &mut Vec<u8>, k| {
b.remove(0);
Parsed::Key(k)
};
match first {
0x1b => {
match b.get(1) {
None => Parsed::Need,
Some(b'[') => {
let Some(end) = b[2..].iter().position(|c| (0x40..=0x7e).contains(c)) else {
return Parsed::Need;
};
let seq: Vec<u8> = b[2..2 + end + 1].to_vec();
b.drain(..3 + end);
Parsed::Key(csi(&seq))
}
Some(b'O') => match b.get(2) {
None => Parsed::Need,
Some(&c) => {
b.drain(..3);
Parsed::Key(csi(&[c]))
}
},
Some(_) => {
b.remove(0);
Parsed::Need
}
}
}
b'\r' | b'\n' => one(b, Key::Enter),
b'\t' => one(b, Key::Tab),
0x7f | 0x08 => one(b, Key::Backspace),
c if c < 0x20 => one(b, Key::Ctrl((c + b'a' - 1) as char)),
c if c < 0x80 => one(b, Key::Char(c as char)),
c => {
let len = match c {
0xc0..=0xdf => 2,
0xe0..=0xef => 3,
_ => 4,
};
if b.len() < len {
return Parsed::Need;
}
let s = String::from_utf8_lossy(&b[..len]).into_owned();
b.drain(..len);
Parsed::Key(s.chars().next().map_or(Key::Esc, Key::Char))
}
}
}
fn take_one(b: &mut Vec<u8>) -> Key {
match b.remove(0) {
0x1b => Key::Esc,
c if c < 0x20 => Key::Ctrl((c + b'a' - 1) as char),
c => Key::Char(c as char),
}
}
impl Drop for Term {
fn drop(&mut self) {
restore();
}
}
fn csi(seq: &[u8]) -> Key {
match seq {
b"A" => Key::Up,
b"B" => Key::Down,
b"C" => Key::Right,
b"D" => Key::Left,
b"H" | b"1~" | b"7~" => Key::Home,
b"F" | b"4~" | b"8~" => Key::End,
b"5~" => Key::PageUp,
b"6~" => Key::PageDown,
b"Z" => Key::BackTab,
_ => Key::Esc,
}
}
fn restore() {
if let Some(t) = ORIG.get() {
unsafe { libc::tcsetattr(0, libc::TCSANOW, t) };
}
const OFF: &[u8] = b"\x1b[?25h\x1b[?1049l";
unsafe { libc::write(1, OFF.as_ptr().cast(), OFF.len()) };
}
fn on_signal(sig: libc::c_int, h: unsafe extern "C" fn(libc::c_int)) {
let mut sa: libc::sigaction = unsafe { std::mem::zeroed() };
sa.sa_sigaction = h as usize;
unsafe {
libc::sigemptyset(&mut sa.sa_mask);
libc::sigaction(sig, &sa, std::ptr::null_mut());
}
}
unsafe extern "C" fn winch(_: libc::c_int) {}
unsafe extern "C" fn bail(sig: libc::c_int) {
restore();
unsafe { libc::_exit(128 + sig) };
}
fn ok_or_errno(rc: libc::c_int) -> io::Result<()> {
match rc {
0 => Ok(()),
_ => Err(io::Error::last_os_error()),
}
}
fn poll_in(timeout_ms: i32) -> io::Result<bool> {
let mut p = libc::pollfd {
fd: 0,
events: libc::POLLIN,
revents: 0,
};
let n = unsafe { libc::poll(&mut p, 1, timeout_ms) };
if n >= 0 {
return Ok(n > 0);
}
let e = io::Error::last_os_error();
match e.kind() {
io::ErrorKind::Interrupted => Ok(false),
_ => Err(e),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Key {
Char(char),
Ctrl(char),
Up,
Down,
Left,
Right,
Enter,
Esc,
Tab,
BackTab,
Backspace,
Home,
End,
PageUp,
PageDown,
}
pub const RESET: &str = "\x1b[0m";
pub const BOLD: &str = "\x1b[1m";
pub const DIM: &str = "\x1b[2m";
pub const REV: &str = "\x1b[7m";
pub const RED: &str = "\x1b[31m";
pub const GREEN: &str = "\x1b[32m";
pub const YELLOW: &str = "\x1b[33m";
pub const BLUE: &str = "\x1b[34m";
pub const MAGENTA: &str = "\x1b[35m";
pub const CYAN: &str = "\x1b[36m";
pub struct Row {
buf: String,
width: usize,
max: usize,
}
impl Row {
pub fn new(max: usize) -> Row {
Row {
buf: String::with_capacity(max + 32),
width: 0,
max,
}
}
pub fn left(&self) -> usize {
self.max.saturating_sub(self.width)
}
pub fn cap(&mut self, max: usize) -> &mut Row {
self.max = max;
self
}
pub fn put(&mut self, style: &str, s: &str) -> &mut Row {
let left = self.left();
if left == 0 {
return self;
}
if !style.is_empty() {
self.buf.push_str(style);
}
let mut n = 0;
for c in s.chars() {
if n == left {
break;
}
self.buf.push(if (c as u32) < 0x20 { '·' } else { c });
n += 1;
}
if !style.is_empty() {
self.buf.push_str(RESET);
}
self.width += n;
self
}
pub fn plain(&mut self, s: &str) -> &mut Row {
self.put("", s)
}
pub fn raw(&mut self, s: &str, width: usize) -> &mut Row {
self.buf.push_str(s);
self.width += width;
self
}
pub fn pad_to(&mut self, col: usize) -> &mut Row {
while self.width < col.min(self.max) {
self.buf.push(' ');
self.width += 1;
}
self
}
pub fn repeat(&mut self, style: &str, c: char, n: usize) -> &mut Row {
let n = n.min(self.left());
if n == 0 {
return self;
}
if !style.is_empty() {
self.buf.push_str(style);
}
for _ in 0..n {
self.buf.push(c);
}
if !style.is_empty() {
self.buf.push_str(RESET);
}
self.width += n;
self
}
pub fn fill(mut self, style: &str) -> String {
if !style.is_empty() {
self.buf.push_str(style);
}
while self.width < self.max {
self.buf.push(' ');
self.width += 1;
}
self.buf.push_str(RESET);
self.buf
}
pub fn done(self) -> String {
self.fill("")
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
#[test]
fn styling_does_not_count_against_the_width() {
let mut r = Row::new(10);
r.put(RED, "abc").plain("de");
assert_eq!(r.left(), 5);
let s = r.done();
assert!(s.contains(RED));
let visible: String = strip(&s);
assert_eq!(visible, "abcde ");
}
#[test]
fn text_is_clipped_at_the_edge_and_control_bytes_are_defanged() {
let mut r = Row::new(6);
r.plain("a\nb").plain("xxxxxxxx");
assert_eq!(strip(&r.done()), "a·bxxx");
}
#[test]
fn pad_to_never_moves_backwards() {
let mut r = Row::new(12);
r.plain("overlong").pad_to(4).plain("|");
assert_eq!(strip(&r.done()), "overlong| ");
}
#[test]
fn escape_sequences_decode_to_keys() {
let mut pending: Vec<u8> = Vec::new();
let mut keys = |bytes: &[u8]| {
pending.extend_from_slice(bytes);
let mut out = Vec::new();
while let Parsed::Key(k) = parse(&mut pending) {
out.push(k);
}
out
};
assert_eq!(keys(b"\x1b[A\x1b[B"), vec![Key::Up, Key::Down]);
assert_eq!(keys(b"\x1b[5~\x1b[6~"), vec![Key::PageUp, Key::PageDown]);
assert_eq!(keys(b"\x1bOD"), vec![Key::Left]);
assert_eq!(
keys(b"jk\r\x7f"),
vec![Key::Char('j'), Key::Char('k'), Key::Enter, Key::Backspace]
);
assert_eq!(keys(b"\x03"), vec![Key::Ctrl('c')]);
assert_eq!(keys(b"\x1b["), vec![]);
assert_eq!(keys(b"C"), vec![Key::Right]);
assert_eq!(keys(b"\x1bO"), vec![]);
assert_eq!(keys(b"A"), vec![Key::Up]);
assert_eq!(keys(&[0xc3]), vec![]);
assert_eq!(keys(&[0xa9]), vec![Key::Char('é')]);
assert_eq!(keys(&[0xe2, 0x82]), vec![]);
assert_eq!(keys(&[0xac]), vec![Key::Char('€')]);
assert_eq!(keys(&[0xf0, 0x9f, 0x98]), vec![]);
assert_eq!(keys(&[0x80]), vec![Key::Char('😀')]);
}
#[test]
fn one_key_arrives_in_as_many_spellings_as_there_are_terminals() {
fn key(bytes: &[u8]) -> Option<Key> {
match parse(&mut bytes.to_vec()) {
Parsed::Key(k) => Some(k),
Parsed::Need => None,
}
}
assert_eq!(key(b"\x1b[1"), None);
for (bytes, want) in [
(&b"\x1b[H"[..], Some(Key::Home)),
(b"\x1b[1~", Some(Key::Home)),
(b"\x1b[7~", Some(Key::Home)),
(b"\x1b[F", Some(Key::End)),
(b"\x1b[4~", Some(Key::End)),
(b"\x1b[8~", Some(Key::End)),
(b"\x1b[Z", Some(Key::BackTab)),
(b"\x1b[C", Some(Key::Right)),
(b"\x1bOA", Some(Key::Up)),
(b"\x1b[200~", Some(Key::Esc)),
(b"\t", Some(Key::Tab)),
(b"\x08", Some(Key::Backspace)),
(b"\n", Some(Key::Enter)),
(b"", None),
(b"\x1b[", None),
(b"\x1bO", None),
] {
assert_eq!(key(bytes), want, "{bytes:?}");
}
let mut b = b"\x1bx".to_vec();
assert!(matches!(parse(&mut b), Parsed::Need));
assert_eq!(key(&b), Some(Key::Char('x')));
for (byte, want) in [
(0x1b, Key::Esc),
(0x03, Key::Ctrl('c')),
(b'q', Key::Char('q')),
] {
let mut b = vec![byte, b'!'];
assert_eq!(take_one(&mut b), want);
assert_eq!(b, b"!");
}
}
#[test]
fn a_row_composes_out_of_other_rows_without_recounting_their_escapes() {
let mut r = Row::new(20);
r.plain("left").cap(20).pad_to(12).plain("right");
assert_eq!(strip(&r.done()), "left right ");
let inner = {
let mut i = Row::new(5);
i.put(RED, "ab");
i.done()
};
let mut outer = Row::new(10);
outer.raw(&inner, 5).plain("xy");
let s = outer.done();
assert!(s.contains(RED), "the inner styling survived byte for byte");
assert_eq!(strip(&s), "ab xy ");
let mut r = Row::new(4);
r.repeat(DIM, '-', 99);
assert_eq!(strip(&r.done()), "----");
let mut r = Row::new(0);
r.repeat(DIM, '-', 3).put(RED, "x");
assert_eq!(r.done(), RESET, "nothing fits, so nothing is written");
let mut r = Row::new(6);
r.plain("ab");
let s = r.fill(REV);
assert!(s.ends_with(&format!("{REV} {RESET}")), "{s:?}");
}
#[test]
fn the_tui_refuses_a_stdin_that_is_not_a_terminal() {
let e = Term::enter()
.err()
.expect("cargo test does not run on a tty");
assert_eq!(e.kind(), io::ErrorKind::Unsupported);
assert!(e.to_string().contains("needs stdin and stdout on a tty"));
}
#[test]
fn a_failed_termios_call_carries_the_os_error() {
let mut fds = [-1; 2];
assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0);
let mut t: libc::termios = unsafe { std::mem::zeroed() };
let get = ok_or_errno(unsafe { libc::tcgetattr(fds[0], &mut t) });
let set = ok_or_errno(unsafe { libc::tcsetattr(fds[0], libc::TCSANOW, &t) });
unsafe {
libc::close(fds[0]);
libc::close(fds[1]);
}
for r in [get, set] {
assert_eq!(r.unwrap_err().raw_os_error(), Some(libc::ENOTTY));
}
}
const ON_POLL: &str = "term::tests::a_poll_that_fails_for_anything_but_a_signal_is_an_error";
#[test]
fn a_poll_that_fails_for_anything_but_a_signal_is_an_error() {
if std::env::var_os("MIRA_POLL_CHILD").is_none() {
let out = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", ON_POLL, "--nocapture"])
.env("MIRA_POLL_CHILD", "1")
.output()
.unwrap();
let err = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "{err}");
return;
}
let mut orig: libc::rlimit = unsafe { std::mem::zeroed() };
let read = unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut orig) };
assert_eq!(read, 0, "getrlimit: {}", io::Error::last_os_error());
let none = libc::rlimit {
rlim_cur: 0,
rlim_max: orig.rlim_max,
};
assert_eq!(unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &none) }, 0);
let got = poll_in(0);
assert_eq!(unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &orig) }, 0);
assert_eq!(got.unwrap_err().raw_os_error(), Some(libc::EINVAL));
}
fn strip(s: &str) -> String {
let mut out = String::new();
let mut it = s.chars();
while let Some(c) = it.next() {
if c == '\x1b' {
for c in it.by_ref() {
if c.is_ascii_alphabetic() {
break;
}
}
} else {
out.push(c);
}
}
out
}
const SELF: &str = "term::tests::the_terminal_half_runs_against_a_real_pty";
#[test]
fn the_terminal_half_runs_against_a_real_pty() {
if std::env::var_os("MIRA_PTY_CHILD").is_some() {
return on_the_pty();
}
let Pty {
screen,
err,
stalled,
trailing,
} = drive(
SELF,
&[],
&[
("mira-pty-ready", b"\x1b[B"),
("mira-pty-down", b"x"),
("mira-pty-x", b"\x1b"),
],
"\x1b[?25h\x1b[?1049l",
);
assert!(
stalled.is_none() && err.contains("mira-pty-done"),
"child stalled at {stalled:?}: {err}\nscreen:\n{screen:?}"
);
assert!(screen.contains("\x1b[H"), "{screen:?}");
assert!(screen.contains("mira-pty-ready\x1b[K\r\n"), "{screen:?}");
assert!(trailing, "{screen:?}");
}
pub(crate) struct Pty {
pub screen: String,
pub err: String,
pub stalled: Option<&'static str>,
pub trailing: bool,
}
pub(crate) fn drive(
test: &str,
env: &[(&str, &std::ffi::OsStr)],
script: &[(&'static str, &'static [u8])],
trailing: &str,
) -> Pty {
use std::os::fd::FromRawFd;
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};
let master = unsafe { libc::posix_openpt(libc::O_RDWR | libc::O_NOCTTY) };
assert!(master >= 0, "{}", io::Error::last_os_error());
assert_eq!(unsafe { libc::grantpt(master) }, 0);
assert_eq!(unsafe { libc::unlockpt(master) }, 0);
let slave_path = unsafe { std::ffi::CStr::from_ptr(libc::ptsname(master)) }
.to_str()
.unwrap()
.to_owned();
let slave = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(&slave_path)
.unwrap();
let ws = libc::winsize {
ws_row: 40,
ws_col: 120,
ws_xpixel: 0,
ws_ypixel: 0,
};
let fd = std::os::fd::AsRawFd::as_raw_fd(&slave);
let sized = unsafe { libc::ioctl(fd, libc::TIOCSWINSZ as _, &ws) };
assert_eq!(sized, 0, "TIOCSWINSZ: {}", io::Error::last_os_error());
let child = Command::new(std::env::current_exe().unwrap())
.args(["--exact", test, "--nocapture"])
.env("MIRA_PTY_CHILD", "1")
.envs(env.iter().copied())
.stdin(Stdio::from(slave.try_clone().unwrap()))
.stdout(Stdio::from(slave.try_clone().unwrap()))
.stderr(Stdio::piped())
.spawn()
.unwrap();
drop(slave);
let mut rd = unsafe { std::fs::File::from_raw_fd(master) };
let mut wr = rd.try_clone().unwrap();
let screen = Arc::new(Mutex::new(String::new()));
let sink = screen.clone();
std::thread::spawn(move || {
let mut buf = [0u8; 4096];
while let Ok(n) = rd.read(&mut buf) {
if n == 0 {
break;
}
sink.lock()
.unwrap()
.push_str(&String::from_utf8_lossy(&buf[..n]));
}
});
let arrived = |marker: &str| {
(0..600).any(|_| {
if screen.lock().unwrap().contains(marker) {
return true;
}
std::thread::sleep(std::time::Duration::from_millis(10));
false
})
};
let mut child = child;
let stalled = script
.iter()
.find(|(marker, keys)| !arrived(marker) || wr.write_all(keys).is_err())
.map(|(marker, _)| *marker);
let _ = stalled.map(|_| child.kill());
let out = child.wait_with_output().unwrap();
let trailing = arrived(trailing);
Pty {
screen: screen.lock().unwrap().clone(),
err: String::from_utf8_lossy(&out.stderr).into_owned(),
stalled,
trailing,
}
}
fn on_the_pty() {
let mut t = Term::enter().expect("stdin and stdout are the pty slave");
assert_eq!(t.size(), (120, 40), "the size the parent set");
let sizes = [(0, 0), (40, 120)].map(|(ws_row, ws_col)| libc::winsize {
ws_row,
ws_col,
ws_xpixel: 0,
ws_ypixel: 0,
});
for (ws, want) in sizes.iter().zip([(80, 24), (120, 40)]) {
let rc = unsafe { libc::ioctl(1, libc::TIOCSWINSZ as _, ws) };
assert_eq!(rc, 0, "TIOCSWINSZ: {}", io::Error::last_os_error());
assert_eq!(t.size(), want, "{} columns", ws.ws_col);
}
assert_eq!(t.key(30).unwrap(), None, "empty poll");
struct Tid(libc::pthread_t);
unsafe impl Send for Tid {}
let me = Tid(unsafe { libc::pthread_self() });
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(30));
unsafe { libc::pthread_kill(me.0, libc::SIGWINCH) };
});
assert_eq!(t.key(-1).unwrap(), None, "a resize interrupts the poll");
for sig in [libc::SIGWINCH, libc::SIGTERM, libc::SIGHUP] {
let mut sa: libc::sigaction = unsafe { std::mem::zeroed() };
let got = unsafe { libc::sigaction(sig, std::ptr::null(), &mut sa) };
assert_eq!(got, 0, "reading the disposition of {sig}");
assert!(
sa.sa_sigaction != libc::SIG_DFL && sa.sa_sigaction != libc::SIG_IGN,
"signal {sig} has no handler"
);
}
t.draw(&["mira-pty-ready".into(), "second row".into()])
.unwrap();
assert_eq!(t.key(-1).unwrap(), Some(Key::Down));
t.draw(&["mira-pty-down".into()]).unwrap();
assert_eq!(t.key(-1).unwrap(), Some(Key::Char('x')));
t.draw(&["mira-pty-x".into()]).unwrap();
assert_eq!(t.key(-1).unwrap(), Some(Key::Esc));
let zero = libc::winsize {
ws_row: 0,
ws_col: 0,
ws_xpixel: 0,
ws_ypixel: 0,
};
let sized = unsafe { libc::ioctl(1, libc::TIOCSWINSZ as _, &zero) };
assert_eq!(sized, 0, "TIOCSWINSZ 0x0: {}", io::Error::last_os_error());
assert_eq!(t.size(), (80, 24), "no size is 80x24, not 0x0");
let devnull = std::fs::File::open("/dev/null").unwrap();
let null_fd = std::os::fd::AsRawFd::as_raw_fd(&devnull);
assert_eq!(unsafe { libc::dup2(null_fd, 0) }, 0);
assert_eq!(t.key(0).unwrap(), Some(Key::Ctrl('c')), "EOF is a quit");
panic!("mira-pty-done");
}
}