use std::time::Duration;
use crate::primer::Rgb;
const PROBE_ID: u32 = 7379;
#[cfg(unix)]
const QUERIES: &str = concat!(
"\x1b_Gi=7379,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\",
"\x1b]11;?\x1b\\",
"\x1b[16t",
"\x1b[14t",
"\x1b[c",
);
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Caps {
pub kitty: bool,
pub sixel: bool,
pub cell: Option<(u16, u16)>,
pub window: Option<(u16, u16)>,
pub background: Option<Rgb>,
pub answered: bool,
}
#[cfg(unix)]
pub fn probe(timeout: Duration) -> Caps {
use std::fs::OpenOptions;
use std::io::{ErrorKind, Read, Write};
use std::os::unix::fs::OpenOptionsExt;
use std::time::Instant;
let Ok(mut tty) = OpenOptions::new()
.read(true)
.write(true)
.custom_flags(libc::O_NONBLOCK)
.open("/dev/tty")
else {
return Caps::default();
};
if tty.write_all(QUERIES.as_bytes()).is_err() || tty.flush().is_err() {
return Caps::default();
}
let deadline = Instant::now() + timeout;
let mut reply = Vec::new();
let mut chunk = [0u8; 1024];
loop {
match tty.read(&mut chunk) {
Ok(0) => break,
Ok(read) => {
reply.extend_from_slice(&chunk[..read]);
if attributes(&String::from_utf8_lossy(&reply)).is_some() {
break;
}
}
Err(error) if error.kind() == ErrorKind::WouldBlock => {
if Instant::now() >= deadline {
break;
}
std::thread::sleep(Duration::from_millis(2));
}
Err(_) => break,
}
}
parse(&String::from_utf8_lossy(&reply))
}
#[cfg(not(unix))]
pub fn probe(_timeout: Duration) -> Caps {
Caps::default()
}
pub fn parse(reply: &str) -> Caps {
let mut caps = Caps {
answered: !reply.is_empty(),
..Caps::default()
};
caps.kitty = reply.contains(&format!("_Gi={PROBE_ID};OK"));
if let Some(attributes) = attributes(reply) {
caps.sixel = attributes.split(';').any(|item| item == "4");
}
caps.cell = report(reply, "\x1b[6;").map(|(height, width)| (width, height));
caps.window = report(reply, "\x1b[4;").map(|(height, width)| (width, height));
caps.background = background(reply);
caps
}
fn attributes(reply: &str) -> Option<&str> {
reply.split("\x1b[?").skip(1).find_map(|rest| {
let body = &rest[..rest.find('c')?];
body.chars()
.all(|c| c.is_ascii_digit() || c == ';')
.then_some(body)
})
}
fn report(reply: &str, prefix: &str) -> Option<(u16, u16)> {
let rest = reply.split(prefix).nth(1)?;
let body = &rest[..rest.find('t')?];
let (first, second) = body.split_once(';')?;
let (first, second) = (first.trim().parse().ok()?, second.trim().parse().ok()?);
(first > 0 && second > 0).then_some((first, second))
}
fn background(reply: &str) -> Option<Rgb> {
let rest = reply.split("\x1b]11;").nth(1)?;
let body = rest.split(['\x07', '\x1b']).next()?;
fn channel(text: &str) -> Option<u8> {
let value = u32::from_str_radix(text, 16).ok()?;
let full = ((1u32 << (4 * text.len().min(4))) - 1).max(1);
Some(((value * 255 + full / 2) / full) as u8)
}
if let Some(channels) = body
.strip_prefix("rgb:")
.or_else(|| body.strip_prefix("rgba:"))
{
let mut scaled = channels.split('/').take(3).map(channel);
return Some(Rgb(scaled.next()??, scaled.next()??, scaled.next()??));
}
let hex = body.strip_prefix('#')?;
if hex.is_empty() || !hex.len().is_multiple_of(3) || hex.len() > 12 {
return None;
}
let each = hex.len() / 3;
Some(Rgb(
channel(&hex[..each])?,
channel(&hex[each..each * 2])?,
channel(&hex[each * 2..])?,
))
}
pub const MAX_CELL: u16 = 64;
pub fn cell_size(caps: &Caps) -> Option<(u16, u16)> {
let sane = |(width, height): (u16, u16)| -> Option<(u16, u16)> {
(width >= 2 && height >= 2 && width <= MAX_CELL && height <= MAX_CELL)
.then_some((width, height))
};
if let Ok(size) = ratatui::crossterm::terminal::window_size() {
if size.width > 0 && size.height > 0 && size.columns > 0 && size.rows > 0 {
if let Some(cell) = sane((size.width / size.columns, size.height / size.rows)) {
return Some(cell);
}
}
}
if let Some(cell) = caps.cell.and_then(sane) {
return Some(cell);
}
let (width, height) = caps.window?;
let (columns, rows) = ratatui::crossterm::terminal::size().ok()?;
(columns > 0 && rows > 0)
.then(|| (width / columns, height / rows))
.and_then(sane)
}