use std::time::Duration;
use crate::capabilities::{
Capabilities, ColorDepth, Environment, Field, Graphics, Origin, Report, SystemEnvironment,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct CellPixels {
pub width: u16,
pub height: u16,
}
impl CellPixels {
pub fn new(width: u16, height: u16) -> Option<CellPixels> {
(width > 0 && height > 0).then_some(CellPixels { width, height })
}
pub fn from_window(pixels: (u16, u16), cells: (u16, u16)) -> Option<CellPixels> {
if cells.0 == 0 || cells.1 == 0 {
return None;
}
CellPixels::new(pixels.0 / cells.0, pixels.1 / cells.1)
}
pub fn parse(value: &str) -> Option<CellPixels> {
let (w, h) = value.trim().split_once(['x', 'X'])?;
CellPixels::new(w.trim().parse().ok()?, h.trim().parse().ok()?)
}
}
impl std::fmt::Display for CellPixels {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}x{}", self.width, self.height)
}
}
pub fn parse_cell_pixels_reply(bytes: &[u8]) -> Option<CellPixels> {
let text = std::str::from_utf8(bytes).ok()?;
let start = text.find("\x1b[6;")?;
let body = &text[start + 4..];
let end = body.find('t')?;
let (height, width) = body[..end].split_once(';')?;
CellPixels::new(width.parse().ok()?, height.parse().ok()?)
}
pub fn query_cell_pixels(timeout: Duration) -> Option<CellPixels> {
#[cfg(unix)]
{
query_unix(timeout)
}
#[cfg(not(unix))]
{
let _ = timeout;
None
}
}
#[cfg(unix)]
fn query_unix(timeout: Duration) -> Option<CellPixels> {
use rustix::fs::{open, Mode, OFlags};
use rustix::termios::{tcgetattr, tcsetattr, OptionalActions};
let tty = open(
"/dev/tty",
OFlags::RDWR | OFlags::NOCTTY | OFlags::CLOEXEC,
Mode::empty(),
)
.ok()?;
if !in_foreground(&tty) {
return None;
}
let _blocked = JobSignals::block()?;
if !in_foreground(&tty) {
return None;
}
let saved = tcgetattr(&tty).ok()?;
let mut raw = saved.clone();
raw.make_raw();
tcsetattr(&tty, OptionalActions::Now, &raw).ok()?;
let cell = if input_waiting(&tty) {
None
} else {
exchange(&tty, timeout).0
};
let _ = tcsetattr(&tty, OptionalActions::Now, &saved);
cell
}
#[cfg(unix)]
fn in_foreground(tty: &rustix::fd::OwnedFd) -> bool {
rustix::termios::tcgetpgrp(tty).is_ok_and(|group| group == rustix::process::getpgrp())
}
#[cfg(unix)]
fn input_waiting(tty: &rustix::fd::OwnedFd) -> bool {
readable(tty, Duration::ZERO) != Some(false)
}
#[cfg(unix)]
#[allow(unsafe_code)]
fn readable(tty: &rustix::fd::OwnedFd, timeout: Duration) -> Option<bool> {
use rustix::fd::AsRawFd;
let fd = tty.as_raw_fd();
if !(0..libc::FD_SETSIZE as i32).contains(&fd) {
return None;
}
let mut wait = libc::timeval {
tv_sec: timeout.as_secs().try_into().unwrap_or(libc::time_t::MAX),
tv_usec: timeout.subsec_micros() as _,
};
let ready = unsafe {
let mut set: libc::fd_set = std::mem::zeroed();
libc::FD_ZERO(&mut set);
libc::FD_SET(fd, &mut set);
libc::select(
fd + 1,
&mut set,
std::ptr::null_mut(),
std::ptr::null_mut(),
&mut wait,
)
};
match ready {
0 => Some(false),
n if n > 0 => Some(true),
_ => None,
}
}
#[cfg(unix)]
struct JobSignals(libc::sigset_t);
#[cfg(unix)]
#[allow(unsafe_code)]
impl JobSignals {
fn block() -> Option<JobSignals> {
unsafe {
let mut set: libc::sigset_t = std::mem::zeroed();
libc::sigemptyset(&mut set);
libc::sigaddset(&mut set, libc::SIGTTOU);
libc::sigaddset(&mut set, libc::SIGTTIN);
let mut old: libc::sigset_t = std::mem::zeroed();
(libc::pthread_sigmask(libc::SIG_BLOCK, &set, &mut old) == 0).then_some(JobSignals(old))
}
}
}
#[cfg(unix)]
#[allow(unsafe_code)]
impl Drop for JobSignals {
fn drop(&mut self) {
unsafe {
libc::pthread_sigmask(libc::SIG_SETMASK, &self.0, std::ptr::null_mut());
}
}
}
#[derive(Debug, PartialEq, Eq)]
#[cfg_attr(not(unix), allow(dead_code))]
enum Reply {
Partial,
CellSize(Option<CellPixels>),
Attributes,
Other,
}
#[cfg_attr(not(unix), allow(dead_code))]
fn classify(sequence: &[u8]) -> Reply {
let Some(rest) = sequence.strip_prefix(b"\x1b") else {
return Reply::Other;
};
let Some((&first, rest)) = rest.split_first() else {
return Reply::Partial;
};
if first != b'[' {
return Reply::Other;
}
let (attributes, body) = match rest.split_first() {
None => return Reply::Partial,
Some((b'?', body)) => (true, body),
Some(_) => (false, rest),
};
let Some((&last, params)) = body.split_last() else {
return Reply::Partial;
};
let in_params = |b: &u8| b.is_ascii_digit() || *b == b';';
if !params.iter().all(in_params) || sequence.len() > 32 {
return Reply::Other;
}
match (attributes, last) {
(_, b) if in_params(&b) => Reply::Partial,
(true, b'c') => Reply::Attributes,
(false, b't') => Reply::CellSize(parse_cell_pixels_reply(sequence)),
_ => Reply::Other,
}
}
#[cfg(unix)]
fn exchange(tty: &rustix::fd::OwnedFd, timeout: Duration) -> (Option<CellPixels>, Vec<u8>) {
use std::time::Instant;
if rustix::io::write(tty, b"\x1b[16t\x1b[c").is_err() {
return (None, Vec::new());
}
let deadline = Instant::now() + timeout;
let mut cell = None;
let mut sequence: Vec<u8> = Vec::new();
let mut stray: Vec<u8> = Vec::new();
while let Some(left) = deadline.checked_duration_since(Instant::now()) {
if readable(tty, left) != Some(true) {
break;
}
let mut byte = [0u8; 1];
if !matches!(rustix::io::read(tty, &mut byte), Ok(1)) {
break;
}
if sequence.is_empty() && byte[0] != 0x1b {
stray.push(byte[0]);
continue;
}
sequence.push(byte[0]);
match classify(&sequence) {
Reply::Partial => {}
Reply::CellSize(found) => {
cell = cell.or(found);
sequence.clear();
}
Reply::Attributes => {
sequence.clear();
break;
}
Reply::Other => stray.append(&mut sequence),
}
}
stray.append(&mut sequence);
(cell, stray)
}
pub(crate) fn stdout_window() -> Option<((u16, u16), (u16, u16))> {
#[cfg(unix)]
{
let size = rustix::termios::tcgetwinsize(std::io::stdout()).ok()?;
Some(((size.ws_col, size.ws_row), (size.ws_xpixel, size.ws_ypixel)))
}
#[cfg(not(unix))]
{
None
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct GraphicsEnvironment {
pub graphics: Field<Graphics>,
pub sixel: Field<bool>,
pub cell_pixels: Field<Option<CellPixels>>,
pub interactive: bool,
pub color: ColorDepth,
pub unicode: bool,
pub animation: Field<bool>,
}
impl GraphicsEnvironment {
pub fn from_report(report: &Report, env: &dyn Environment) -> GraphicsEnvironment {
let cell_pixels = match env.var("RICH_CELL_PIXELS").filter(|v| !v.is_empty()) {
Some(value) => match CellPixels::parse(&value) {
Some(cell) => Field::new(
Some(cell),
Origin::Environment("RICH_CELL_PIXELS".into()),
format!("RICH_CELL_PIXELS={cell}"),
),
None => Field::new(None, Origin::Default, "RICH_CELL_PIXELS is not WxH"),
},
None => match env.cell_pixels().and_then(|(w, h)| CellPixels::new(w, h)) {
Some(cell) if report.interactive.value => Field::new(
Some(cell),
Origin::Inferred,
"terminal window size in pixels",
),
_ => Field::new(None, Origin::Default, "the terminal did not report pixels"),
},
};
GraphicsEnvironment {
graphics: report.graphics.clone(),
sixel: report.sixel.clone(),
cell_pixels,
interactive: report.interactive.value,
color: report.color.value,
unicode: report.unicode.value,
animation: report.animation.clone(),
}
}
pub fn detect(env: &dyn Environment) -> GraphicsEnvironment {
GraphicsEnvironment::from_report(&Capabilities::detect(env), env)
}
pub fn system() -> GraphicsEnvironment {
use std::io::IsTerminal;
let mut environment = GraphicsEnvironment::detect(&SystemEnvironment);
if environment.cell_pixels.value.is_none()
&& environment.interactive
&& std::io::stdin().is_terminal()
&& environment.graphics.value != Graphics::None
{
if let Some(cell) = query_cell_pixels(Duration::from_millis(500)) {
environment.cell_pixels =
Field::new(Some(cell), Origin::Inferred, "CSI 16 t reply");
}
}
environment
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::capabilities::MapEnvironment;
#[test]
fn parses_cell_sizes() {
assert_eq!(CellPixels::parse("8x16"), CellPixels::new(8, 16));
assert_eq!(CellPixels::parse(" 10 X 20 "), CellPixels::new(10, 20));
assert_eq!(CellPixels::parse("0x16"), None);
assert_eq!(CellPixels::parse("8"), None);
assert_eq!(
CellPixels::from_window((800, 600), (100, 30)),
CellPixels::new(8, 20)
);
assert_eq!(CellPixels::from_window((0, 0), (100, 30)), None);
}
#[test]
fn parses_the_query_reply() {
assert_eq!(
parse_cell_pixels_reply(b"junk\x1b[6;18;9t"),
CellPixels::new(9, 18)
);
assert_eq!(parse_cell_pixels_reply(b"\x1b[6;18"), None);
assert_eq!(parse_cell_pixels_reply(b"\x1b[4;600;800t"), None);
}
#[test]
fn classifies_what_the_query_reads() {
assert_eq!(classify(b"\x1b"), Reply::Partial);
assert_eq!(classify(b"\x1b["), Reply::Partial);
assert_eq!(classify(b"\x1b[6;18"), Reply::Partial);
assert_eq!(classify(b"\x1b[?62;2"), Reply::Partial);
assert_eq!(
classify(b"\x1b[6;18;9t"),
Reply::CellSize(CellPixels::new(9, 18))
);
assert_eq!(classify(b"\x1b[4;600;800t"), Reply::CellSize(None));
assert_eq!(classify(b"\x1b[?62;22c"), Reply::Attributes);
assert_eq!(classify(b"\x1b[A"), Reply::Other);
assert_eq!(classify(b"\x1bOP"), Reply::Other);
assert_eq!(classify(b"\x1b\x1b"), Reply::Other);
assert_eq!(classify(b"\x1b[1;5D"), Reply::Other);
}
#[test]
fn cell_size_comes_from_the_window_on_a_terminal_only() {
let tty = MapEnvironment::tty()
.var("TERM", "xterm-kitty")
.cell_pixels(9, 18);
let environment = GraphicsEnvironment::detect(&tty);
assert_eq!(environment.graphics.value, Graphics::Kitty);
assert_eq!(environment.cell_pixels.value, CellPixels::new(9, 18));
let pipe = MapEnvironment::new().cell_pixels(9, 18);
assert_eq!(GraphicsEnvironment::detect(&pipe).cell_pixels.value, None);
let forced = MapEnvironment::new().var("RICH_CELL_PIXELS", "10x20");
let environment = GraphicsEnvironment::detect(&forced);
assert_eq!(environment.cell_pixels.value, CellPixels::new(10, 20));
assert_eq!(
environment.cell_pixels.origin,
Origin::Environment("RICH_CELL_PIXELS".into())
);
}
}