use nix::poll::{PollFd, PollFlags, PollTimeout, poll};
use nix::sys::termios::{self, SetArg, Termios};
use std::os::unix::io::{AsRawFd, BorrowedFd, OwnedFd};
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
use std::time::{Duration, Instant};
static MASTER_FD: AtomicI32 = AtomicI32::new(-1);
static SIGWINCH_PENDING: AtomicBool = AtomicBool::new(false);
const RESIZE_REDRAW_DELAY: Duration = Duration::from_millis(75);
pub fn set_sigwinch_pending() {
SIGWINCH_PENDING.store(true, Ordering::SeqCst);
}
pub fn resize_pty() {
let master = MASTER_FD.load(Ordering::SeqCst);
if master < 0 {
return;
}
let mut ws = unsafe { std::mem::zeroed::<nix::libc::winsize>() };
let ret = unsafe {
nix::libc::ioctl(
nix::libc::STDOUT_FILENO,
nix::libc::TIOCGWINSZ,
&mut ws,
)
};
if ret != 0 || ws.ws_row < 2 || ws.ws_col == 0 {
return;
}
ws.ws_row -= 1;
unsafe {
nix::libc::ioctl(master, nix::libc::TIOCSWINSZ, &ws);
}
}
fn forward_sigwinch() {
let master = MASTER_FD.load(Ordering::SeqCst);
if master < 0 {
return;
}
let mut pgrp: nix::libc::pid_t = 0;
let ret =
unsafe { nix::libc::ioctl(master, nix::libc::TIOCGPGRP, &mut pgrp) };
if ret == 0 && pgrp > 0 {
unsafe {
nix::libc::kill(-pgrp, nix::libc::SIGWINCH);
}
}
}
fn enter_raw_mode() -> Result<Termios, String> {
let stdin = std::io::stdin();
let saved =
termios::tcgetattr(&stdin).map_err(|e| format!("tcgetattr: {e}"))?;
let mut raw = saved.clone();
termios::cfmakeraw(&mut raw);
termios::tcsetattr(&stdin, SetArg::TCSANOW, &raw)
.map_err(|e| format!("tcsetattr raw: {e}"))?;
Ok(saved)
}
fn restore_mode(saved: &Termios) {
let stdin = std::io::stdin();
let _ = termios::tcsetattr(&stdin, SetArg::TCSANOW, saved);
}
struct RawModeGuard(Option<Termios>);
impl RawModeGuard {
fn new(saved: Termios) -> Self {
Self(Some(saved))
}
}
impl Drop for RawModeGuard {
fn drop(&mut self) {
if let Some(saved) = self.0.take() {
restore_mode(&saved);
}
}
}
fn set_initial_size(fd: &OwnedFd, rows: u16, cols: u16) {
let ws = nix::libc::winsize {
ws_row: rows,
ws_col: cols,
ws_xpixel: 0,
ws_ypixel: 0,
};
unsafe {
nix::libc::ioctl(fd.as_raw_fd(), nix::libc::TIOCSWINSZ, &ws);
}
}
fn set_scroll_region(fd: i32, content_rows: u16) {
let seq = format!("\x1b[1;{}r", content_rows);
write_all_raw(fd, seq.as_bytes());
}
pub fn parse_resize_redraw_key(spec: &str) -> Result<Option<Vec<u8>>, String> {
let normalized = spec
.trim()
.to_ascii_lowercase()
.replace(['_', '+', ' '], "-");
if normalized.is_empty()
|| matches!(normalized.as_str(), "off" | "none" | "disabled")
{
return Ok(None);
}
let parts: Vec<&str> =
normalized.split('-').filter(|p| !p.is_empty()).collect();
if parts.len() < 2 {
return Err("expected values like ctrl-l or disabled".into());
}
let mut has_ctrl = false;
let mut has_shift = false;
let mut key = None;
for part in parts {
match part {
"ctrl" | "control" => has_ctrl = true,
"shift" => has_shift = true,
k if k.len() == 1 && k.as_bytes()[0].is_ascii_alphabetic() => {
key = Some(k.as_bytes()[0].to_ascii_uppercase());
}
other => {
return Err(format!("unsupported modifier or key {other:?}"));
}
}
}
if !has_ctrl {
return Err("only ctrl-based redraw keys are supported".into());
}
let Some(key) = key else {
return Err("missing final letter key".into());
};
if has_shift {
}
Ok(Some(vec![key & 0x1f]))
}
fn io_loop(
master: &OwnedFd,
init_rows: u16,
init_cols: u16,
resize_redraw_key: Option<&[u8]>,
) {
let stdin_fd = std::io::stdin().as_raw_fd();
let master_raw = master.as_raw_fd();
let stdin_bfd = unsafe { BorrowedFd::borrow_raw(stdin_fd) };
let master_bfd = unsafe { BorrowedFd::borrow_raw(master_raw) };
let mut buf = [0u8; 8192];
let stdout = nix::libc::STDOUT_FILENO;
let mut content_rows = init_rows - 1;
let mut content_cols = init_cols;
let mut parser = vt100::Parser::new(content_rows, content_cols, 0);
let mut prev_screen = parser.screen().clone();
let mut pending_redraw = false;
let mut pending_resize_redraw_at: Option<Instant> = None;
let mut was_alt_screen = false;
crate::statusbar::update_terminal_state(parser.screen());
let pos = format!("\x1b[{};1H", init_rows);
write_all_raw(stdout, pos.as_bytes());
for _ in 0..content_rows {
write_all_raw(stdout, b"\n");
}
write_all_raw(stdout, b"\x1b[H\x1b[J");
set_scroll_region(stdout, content_rows);
loop {
if SIGWINCH_PENDING.swap(false, Ordering::SeqCst) {
let (rows, cols) =
real_term_size().unwrap_or((init_rows, init_cols));
if rows >= 2 {
let old_content_rows = content_rows;
content_rows = rows - 1;
content_cols = cols;
parser.screen_mut().set_size(content_rows, content_cols);
let screen = parser.screen();
let on_alt = screen.alternate_screen();
if on_alt {
write_all_raw(stdout, b"\x1b[r\x1b[H\x1b[J");
let output = screen.state_formatted();
write_all_raw(stdout, &output);
} else {
write_all_raw(stdout, b"\x1b[r");
if old_content_rows < content_rows {
let seq =
format!("\x1b[{};1H\x1b[2K", old_content_rows + 1);
write_all_raw(stdout, seq.as_bytes());
}
set_scroll_region(stdout, content_rows);
let (row, col) = screen.cursor_position();
let seq = format!("\x1b[{};{}H", row + 1, col + 1);
write_all_raw(stdout, seq.as_bytes());
}
prev_screen = screen.clone();
was_alt_screen = on_alt;
crate::statusbar::update_terminal_state(screen);
crate::statusbar::redraw();
resize_pty();
forward_sigwinch();
if !on_alt && resize_redraw_key.is_some() {
pending_resize_redraw_at =
Some(Instant::now() + RESIZE_REDRAW_DELAY);
}
let _ = crate::statusbar::take_requests();
pending_redraw = false;
}
}
let mut fds = [
PollFd::new(stdin_bfd, PollFlags::POLLIN),
PollFd::new(master_bfd, PollFlags::POLLIN),
];
if crate::statusbar::take_requests() {
pending_redraw = true;
}
match poll(&mut fds, PollTimeout::from(100_u16)) {
Ok(0) => {
if pending_redraw {
crate::statusbar::redraw();
pending_redraw = false;
}
if let Some(deadline) = pending_resize_redraw_at
&& Instant::now() >= deadline
{
if let Some(key) = resize_redraw_key {
write_all_raw(master_raw, key);
}
pending_resize_redraw_at = None;
}
continue;
}
Err(nix::errno::Errno::EINTR) => continue,
Err(_) => break,
Ok(_) => {}
}
if let Some(revents) = fds[1].revents() {
if revents.contains(PollFlags::POLLIN) {
match nix::unistd::read(master_raw, &mut buf) {
Ok(0) => break,
Ok(n) => {
parser.process(&buf[..n]);
let screen = parser.screen();
let now_alt = screen.alternate_screen();
if now_alt || now_alt != was_alt_screen {
forward_keyboard_protocol(stdout, &buf[..n]);
}
if now_alt != was_alt_screen {
if now_alt {
write_all_raw(stdout, b"\x1b[r");
} else {
set_scroll_region(stdout, content_rows);
}
write_all_raw(stdout, b"\x1b[H\x1b[J");
let output = screen.state_formatted();
write_all_raw(stdout, &output);
was_alt_screen = now_alt;
} else if now_alt {
let diff = screen.state_diff(&prev_screen);
write_all_raw(stdout, &diff);
} else {
write_all_raw(stdout, &buf[..n]);
if ends_at_ground_state(&buf[..n]) {
let (row, col) =
parser.screen().cursor_position();
set_scroll_region(stdout, content_rows);
let seq =
format!("\x1b[{};{}H", row + 1, col + 1);
write_all_raw(stdout, seq.as_bytes());
}
}
prev_screen = screen.clone();
crate::statusbar::update_terminal_state(screen);
pending_redraw = true;
if resize_redraw_key.is_some()
&& pending_resize_redraw_at.is_some()
{
pending_resize_redraw_at =
Some(Instant::now() + RESIZE_REDRAW_DELAY);
}
}
Err(nix::errno::Errno::EINTR) => {}
Err(nix::errno::Errno::EIO) => break,
Err(_) => break,
}
}
if revents.contains(PollFlags::POLLHUP)
|| revents.contains(PollFlags::POLLERR)
{
loop {
match nix::unistd::read(master_raw, &mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
parser.process(&buf[..n]);
}
}
}
let screen = parser.screen();
let diff = screen.state_diff(&prev_screen);
write_all_raw(stdout, &diff);
write_all_raw(stdout, b"\x1b[r");
crate::statusbar::redraw();
break;
}
}
let child_quiet = !matches!(
fds[1].revents(),
Some(r) if r.contains(PollFlags::POLLIN)
);
if child_quiet {
if pending_redraw {
crate::statusbar::redraw();
pending_redraw = false;
}
if let Some(deadline) = pending_resize_redraw_at
&& Instant::now() >= deadline
{
if let Some(key) = resize_redraw_key {
write_all_raw(master_raw, key);
}
pending_resize_redraw_at = None;
}
}
if let Some(revents) = fds[0].revents()
&& revents.contains(PollFlags::POLLIN)
{
match nix::unistd::read(stdin_fd, &mut buf) {
Ok(0) => break,
Ok(n) => {
write_all_raw(master_raw, &buf[..n]);
}
Err(nix::errno::Errno::EINTR) => {}
Err(_) => break,
}
}
}
write_all_raw(stdout, b"\x1b[r");
}
fn forward_keyboard_protocol(fd: i32, data: &[u8]) {
let mut st: u8 = 0;
let mut start: usize = 0;
let mut is_kbd = false;
for (i, &b) in data.iter().enumerate() {
match st {
0 => {
if b == 0x1b {
start = i;
is_kbd = false;
st = 1;
}
}
1 => {
if b == b'[' {
st = 2;
} else {
st = 0;
}
}
2 => {
if b == b'>' || b == b'<' || b == b'?' {
is_kbd = true;
st = 3;
} else if (0x40..=0x7e).contains(&b) {
st = 0; } else {
st = 3; }
}
3 => {
if (0x40..=0x7e).contains(&b) {
if is_kbd && b == b'u' {
write_all_raw(fd, &data[start..=i]);
}
st = 0;
}
}
_ => st = 0,
}
}
}
fn ends_at_ground_state(data: &[u8]) -> bool {
let mut st: u8 = 0;
for &b in data {
st = match st {
0 => {
if b == 0x1b {
1
} else {
0
}
}
1 => match b {
b'[' => 2,
b']' | b'P' | b'X' | b'^' | b'_' => 3,
0x20..=0x2f => 1, _ => 0, },
2 => {
if (0x40..=0x7e).contains(&b) {
0 } else {
2 }
}
3 => {
if b == 0x07 {
0 } else if b == 0x1b {
1 } else {
3
}
}
_ => 0,
};
}
st == 0
}
fn write_all_raw(fd: i32, data: &[u8]) {
let mut off = 0;
while off < data.len() {
let n = unsafe {
nix::libc::write(
fd,
data[off..].as_ptr() as *const nix::libc::c_void,
data.len() - off,
)
};
if n <= 0 {
break;
}
off += n as usize;
}
}
pub fn run(
cmd: &mut std::process::Command,
resize_redraw_key: Option<&[u8]>,
) -> Result<i32, String> {
use std::os::unix::process::CommandExt;
let (rows, cols) = real_term_size().unwrap_or((24, 80));
if rows < 2 {
return Err("Terminal too small for status bar".into());
}
let pty =
nix::pty::openpty(None, None).map_err(|e| format!("openpty: {e}"))?;
let master = pty.master;
let slave = pty.slave;
let master_raw = master.as_raw_fd();
unsafe {
let flags = nix::libc::fcntl(master_raw, nix::libc::F_GETFD);
nix::libc::fcntl(
master_raw,
nix::libc::F_SETFD,
flags | nix::libc::FD_CLOEXEC,
);
}
set_initial_size(&master, rows - 1, cols);
let saved = enter_raw_mode()?;
let raw_mode_guard = RawModeGuard::new(saved);
let slave_raw = slave.as_raw_fd();
unsafe {
cmd.pre_exec(move || {
if nix::libc::setsid() == -1 {
return Err(std::io::Error::last_os_error());
}
if nix::libc::ioctl(
slave_raw,
nix::libc::TIOCSCTTY as nix::libc::c_ulong,
0,
) == -1
{
return Err(std::io::Error::last_os_error());
}
nix::libc::dup2(slave_raw, 0);
nix::libc::dup2(slave_raw, 1);
nix::libc::dup2(slave_raw, 2);
if slave_raw > 2 {
nix::libc::close(slave_raw);
}
Ok(())
});
}
let child = cmd
.spawn()
.map_err(|e| format!("Failed to start sandbox: {e}"))?;
let pid = child.id() as i32;
crate::signals::set_child_pid(pid);
MASTER_FD.store(master_raw, Ordering::SeqCst);
drop(slave);
io_loop(&master, rows, cols, resize_redraw_key);
MASTER_FD.store(-1, Ordering::SeqCst);
drop(master);
drop(raw_mode_guard);
let exit_code = crate::signals::wait_child(pid);
std::mem::forget(child);
Ok(exit_code)
}
fn real_term_size() -> Option<(u16, u16)> {
let mut ws = unsafe { std::mem::zeroed::<nix::libc::winsize>() };
let ret = unsafe {
nix::libc::ioctl(
nix::libc::STDOUT_FILENO,
nix::libc::TIOCGWINSZ,
&mut ws,
)
};
if ret == 0 && ws.ws_row > 0 && ws.ws_col > 0 {
Some((ws.ws_row, ws.ws_col))
} else {
None
}
}
#[cfg(test)]
mod tests {
#[test]
fn vt100_screen_tracks_content() {
let mut parser = vt100::Parser::new(24, 80, 0);
parser.process(b"Hello, world!");
let screen = parser.screen();
let row = screen.rows(0, 80).next().unwrap();
assert!(row.starts_with("Hello, world!"));
}
#[test]
fn vt100_diff_produces_output() {
let mut parser = vt100::Parser::new(24, 80, 0);
let prev = parser.screen().clone();
parser.process(b"test output");
let diff = parser.screen().contents_diff(&prev);
assert!(!diff.is_empty());
}
#[test]
fn vt100_resize_preserves_content() {
let mut parser = vt100::Parser::new(24, 80, 0);
parser.process(b"line1\r\nline2\r\nline3");
parser.screen_mut().set_size(30, 100);
let screen = parser.screen();
let row0 = screen.rows(0, 100).next().unwrap();
assert!(row0.starts_with("line1"));
}
#[test]
fn vt100_primary_screen_repaint_after_resize_contains_content() {
let mut parser = vt100::Parser::new(4, 20, 0);
parser.process(b"line1\r\nline2\r\nline3");
parser.screen_mut().set_size(6, 30);
let output = parser.screen().state_formatted();
assert!(!parser.screen().alternate_screen());
assert!(!output.is_empty());
assert!(String::from_utf8_lossy(&output).contains("line1"));
assert!(String::from_utf8_lossy(&output).contains("line2"));
}
#[test]
fn parse_resize_redraw_key_ctrl_l() {
assert_eq!(
super::parse_resize_redraw_key("ctrl-l").unwrap(),
Some(vec![0x0c])
);
}
#[test]
fn parse_resize_redraw_key_ctrl_shift_l_aliases_ctrl_l() {
assert_eq!(
super::parse_resize_redraw_key("ctrl-shift-l").unwrap(),
Some(vec![0x0c])
);
}
#[test]
fn parse_resize_redraw_key_disabled() {
assert_eq!(super::parse_resize_redraw_key("disabled").unwrap(), None);
}
#[test]
fn parse_resize_redraw_key_rejects_unknown() {
assert!(super::parse_resize_redraw_key("alt-l").is_err());
}
#[test]
fn vt100_state_diff_includes_mode_changes() {
let mut parser = vt100::Parser::new(24, 80, 0);
let prev = parser.screen().clone();
parser.process(b"\x1b[?2004h");
let diff = parser.screen().state_diff(&prev);
assert!(parser.screen().bracketed_paste());
assert!(!prev.bracketed_paste());
assert!(!diff.is_empty());
}
#[test]
fn vt100_alt_screen_tracking() {
let mut parser = vt100::Parser::new(24, 80, 0);
assert!(!parser.screen().alternate_screen());
parser.process(b"\x1b[?1049h");
assert!(parser.screen().alternate_screen());
parser.process(b"\x1b[?1049l");
assert!(!parser.screen().alternate_screen());
}
#[test]
fn vt100_mouse_mode_tracking() {
let mut parser = vt100::Parser::new(24, 80, 0);
assert_eq!(
parser.screen().mouse_protocol_mode(),
vt100::MouseProtocolMode::None
);
parser.process(b"\x1b[?1003h");
assert_eq!(
parser.screen().mouse_protocol_mode(),
vt100::MouseProtocolMode::AnyMotion
);
}
#[test]
fn vt100_cursor_visibility() {
let mut parser = vt100::Parser::new(24, 80, 0);
assert!(!parser.screen().hide_cursor());
parser.process(b"\x1b[?25l");
assert!(parser.screen().hide_cursor());
parser.process(b"\x1b[?25h");
assert!(!parser.screen().hide_cursor());
}
use super::ends_at_ground_state;
use super::forward_keyboard_protocol;
use std::os::unix::io::AsRawFd;
fn capture_kbd_forward(data: &[u8]) -> Vec<u8> {
let (r, w) = nix::unistd::pipe().unwrap();
forward_keyboard_protocol(w.as_raw_fd(), data);
drop(w);
let mut out = vec![0u8; 256];
let n = nix::unistd::read(r.as_raw_fd(), &mut out).unwrap_or(0);
out.truncate(n);
out
}
#[test]
fn kbd_push_mode_forwarded() {
let out = capture_kbd_forward(b"\x1b[>1u");
assert_eq!(out, b"\x1b[>1u");
}
#[test]
fn kbd_pop_mode_forwarded() {
let out = capture_kbd_forward(b"\x1b[<u");
assert_eq!(out, b"\x1b[<u");
}
#[test]
fn kbd_query_mode_forwarded() {
let out = capture_kbd_forward(b"\x1b[?u");
assert_eq!(out, b"\x1b[?u");
}
#[test]
fn kbd_mixed_with_other_csi() {
let data = b"\x1b[31m\x1b[>1u\x1b[H";
let out = capture_kbd_forward(data);
assert_eq!(out, b"\x1b[>1u");
}
#[test]
fn kbd_no_false_positives() {
let out = capture_kbd_forward(b"\x1b[31m\x1b[H\x1b[J");
assert!(out.is_empty());
}
#[test]
fn kbd_plain_text_ignored() {
let out = capture_kbd_forward(b"hello world");
assert!(out.is_empty());
}
#[test]
fn ground_state_plain_text() {
assert!(ends_at_ground_state(b"hello world"));
}
#[test]
fn ground_state_complete_csi() {
assert!(ends_at_ground_state(b"\x1b[31m"));
assert!(ends_at_ground_state(b"\x1b[38;2;255;0;0mRed"));
}
#[test]
fn ground_state_incomplete_csi() {
assert!(!ends_at_ground_state(b"\x1b[38;2;255"));
assert!(!ends_at_ground_state(b"\x1b["));
assert!(!ends_at_ground_state(b"\x1b"));
}
#[test]
fn ground_state_text_then_incomplete() {
assert!(!ends_at_ground_state(b"hello\x1b[31"));
}
#[test]
fn ground_state_osc_complete() {
assert!(ends_at_ground_state(b"\x1b]0;title\x07"));
assert!(ends_at_ground_state(b"\x1b]0;title\x1b\\"));
}
#[test]
fn ground_state_osc_incomplete() {
assert!(!ends_at_ground_state(b"\x1b]0;title"));
}
#[test]
fn ground_state_empty() {
assert!(ends_at_ground_state(b""));
}
#[test]
fn ground_state_single_char_escape() {
assert!(ends_at_ground_state(b"\x1b7"));
}
#[test]
fn ground_state_scroll_region_reset() {
assert!(ends_at_ground_state(b"\x1b[r"));
assert!(ends_at_ground_state(
b"\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\x1b[r"
));
}
#[test]
fn ground_state_cup_sequence() {
assert!(ends_at_ground_state(b"\x1b[12;1H"));
assert!(ends_at_ground_state(b"\x1b[1;80H"));
}
}