mod query;
pub use query::Probe as RawTty;
use std::time::Duration;
use crate::geometry::CellSize;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Protocol {
Kitty,
None,
}
#[derive(Clone, Copy, Debug)]
pub struct Terminal {
pub cols: u32,
pub rows: u32,
pub cell: CellSize,
pub protocol: Protocol,
}
const LOCAL_CEILING: Duration = Duration::from_millis(250);
const REMOTE_CEILING: Duration = Duration::from_millis(1500);
impl Terminal {
pub fn detect() -> Terminal {
detect_measured().0
}
}
enum Measured {
NotNeeded,
NoTty,
Link(Option<Duration>),
}
fn detect_measured() -> (Terminal, Measured) {
let (cols, rows, pixel_cell) = window_size();
let env_protocol = protocol_from_env();
let asking = pixel_cell.is_none() || env_protocol.is_none();
let mut probe = asking.then(query::Probe::open).flatten();
let ceiling = reply_ceiling();
let measured = match (asking, probe.as_mut()) {
(false, _) => Measured::NotNeeded,
(true, None) => Measured::NoTty,
(true, Some(p)) => Measured::Link(calibrate(p, ceiling)),
};
let rtt = match measured {
Measured::Link(rtt) => rtt,
_ => None,
};
let mut live = match (probe.as_mut(), rtt) {
(Some(p), Some(rtt)) => Some((p, reply_budget(rtt, ceiling))),
_ => None,
};
let cell = pixel_cell
.or_else(|| live.as_mut().and_then(|(p, b)| query_cell_size(p, *b)))
.unwrap_or(CellSize::FALLBACK);
let protocol = env_protocol.unwrap_or_else(|| {
live.as_mut()
.map_or(Protocol::None, |(p, b)| query_protocol(p, *b))
});
let terminal = Terminal {
cols,
rows,
cell,
protocol,
};
(terminal, measured)
}
fn calibrate(probe: &mut query::Probe, ceiling: Duration) -> Option<Duration> {
let rtt = probe.timed(b"\x1b[5n", ceiling, |b| find(b, b"\x1b[0n").is_some())?;
probe.set_grace(rtt);
Some(rtt)
}
fn reply_budget(rtt: Duration, ceiling: Duration) -> Duration {
(rtt * 4).clamp(LOCAL_CEILING, ceiling)
}
fn reply_ceiling() -> Duration {
if over_ssh() { REMOTE_CEILING } else { LOCAL_CEILING }
}
pub fn over_ssh() -> bool {
std::env::var_os("SSH_TTY").is_some() || std::env::var_os("SSH_CONNECTION").is_some()
}
pub fn current_cells() -> (u32, u32) {
let (cols, rows, _) = window_size();
(cols, rows)
}
fn window_size() -> (u32, u32, Option<CellSize>) {
let ws = [
rustix::stdio::stdout(),
rustix::stdio::stderr(),
rustix::stdio::stdin(),
]
.into_iter()
.find_map(|fd| rustix::termios::tcgetwinsize(fd).ok());
let ws = ws.filter(|w| w.ws_col > 0 && w.ws_row > 0);
let Some(ws) = ws else {
return (80, 24, None);
};
let (cols, rows) = (ws.ws_col as u32, ws.ws_row as u32);
let (xp, yp) = (ws.ws_xpixel as u32, ws.ws_ypixel as u32);
let cell = (xp >= 2 * cols && yp >= 4 * rows).then(|| CellSize {
w: xp / cols,
h: yp / rows,
});
(cols, rows, cell)
}
fn query_cell_size(probe: &mut query::Probe, budget: Duration) -> Option<CellSize> {
let reply = probe.ask(b"\x1b[16t", budget, |b| {
find(b, b"\x1b[6;").is_some_and(|i| b[i..].contains(&b't'))
});
let at = find(&reply, b"\x1b[6;")? + 4;
let tail = &reply[at..];
let end = tail.iter().position(|&c| c == b't')?;
let mut parts = std::str::from_utf8(&tail[..end]).ok()?.split(';');
let h: u32 = parts.next()?.trim().parse().ok()?;
let w: u32 = parts.next()?.trim().parse().ok()?;
(w > 0 && h > 0).then_some(CellSize { w, h })
}
fn protocol_from_env() -> Option<Protocol> {
let term = std::env::var("TERM").unwrap_or_default();
if term.contains("kitty") || term.contains("ghostty") {
return Some(Protocol::Kitty);
}
match std::env::var("TERM_PROGRAM").unwrap_or_default().as_str() {
"ghostty" | "kitty" | "WezTerm" => return Some(Protocol::Kitty),
_ => {}
}
let marks = [
"KITTY_WINDOW_ID",
"GHOSTTY_RESOURCES_DIR",
"GHOSTTY_BIN_DIR",
"WEZTERM_EXECUTABLE",
];
marks
.iter()
.any(|v| std::env::var_os(v).is_some())
.then_some(Protocol::Kitty)
}
fn query_protocol(probe: &mut query::Probe, budget: Duration) -> Protocol {
let reply = probe.ask(b"\x1b[>q\x1b[5n", budget, |b| {
find(b, b"\x1b[0").is_some()
});
for name in [&b"kitty"[..], b"ghostty", b"WezTerm", b"Konsole"] {
if find(&reply, name).is_some() {
return Protocol::Kitty;
}
}
Protocol::None
}
pub fn height_ladder(width: u32, view_h: u32) -> String {
use std::fmt::Write as _;
use std::io::Write as _;
let mut s = String::new();
let Some(mut probe) = query::Probe::open() else {
return " no terminal to ask\n".into();
};
let budget = Duration::from_millis(400);
for (n, h) in [8192u32, 8500, 9000, 9500, 10000, 11000, 12000]
.into_iter()
.enumerate()
{
let id = 700 + n as u32;
let mb = (width as u64 * h as u64 * 4) as f64 / 1e6;
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast());
let row: Vec<u8> = (0..width).flat_map(|_| [0xd0u8, 0x40, 0xa0, 0xff]).collect();
for _ in 0..h {
let _ = z.write_all(&row);
}
let Ok(payload) = z.finish() else { continue };
let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &payload);
let stored = chunked(&mut probe, id, width, h, &b64, budget);
let place =
format!("\x1b_Ga=p,i={id},x=0,y=0,w={width},h={view_h},c=24,r=1,q=1;\x1b\\");
let placed = said(&mut probe, &place, budget);
let _ = writeln!(
s,
" {width} x {h:<6} {mb:>5.0} MB store: {stored:<26} place: {placed}"
);
}
s
}
fn chunked(
probe: &mut query::Probe,
id: u32,
w: u32,
h: u32,
b64: &str,
budget: Duration,
) -> String {
let mut chunks = b64.as_bytes().chunks(4096).peekable();
let mut first = true;
let mut last = String::from("accepted (no complaint)");
while let Some(chunk) = chunks.next() {
let more = u8::from(chunks.peek().is_some());
let head = if first {
format!("\x1b_Ga=t,i={id},q=1,f=32,o=z,s={w},v={h},m={more};")
} else {
format!("\x1b_Gq=1,m={more};")
};
first = false;
let escape = format!("{head}{}\x1b\\", String::from_utf8_lossy(chunk));
let wait = if more == 0 {
budget
} else {
Duration::from_millis(1)
};
let reply = said(probe, &escape, wait);
if more == 0 {
last = reply;
}
}
last
}
fn said(probe: &mut query::Probe, escape: &str, budget: Duration) -> String {
let reply = probe.ask(escape.as_bytes(), budget, |b| find(b, b"\x1b\\").is_some());
if reply.is_empty() {
return "accepted (no complaint)".into();
}
String::from_utf8_lossy(&reply)
.trim_matches(|c: char| c.is_control() || c == '\\')
.to_string()
}
pub fn explain() -> String {
use std::fmt::Write;
let mut s = String::new();
for v in [
"TERM",
"TERM_PROGRAM",
"KITTY_WINDOW_ID",
"GHOSTTY_RESOURCES_DIR",
"GHOSTTY_BIN_DIR",
"WEZTERM_EXECUTABLE",
"SSH_TTY",
"SSH_CONNECTION",
] {
let val = std::env::var(v).unwrap_or_else(|_| "<unset>".into());
let _ = writeln!(s, " {v:<22} {val}");
}
let (cols, rows, pixel_cell) = window_size();
let _ = writeln!(s, " ioctl cells {cols}x{rows}");
let _ = match pixel_cell {
Some(c) => writeln!(s, " ioctl cell pixels {}x{}", c.w, c.h),
None => writeln!(s, " ioctl cell pixels <not reported>"),
};
let _ = match protocol_from_env() {
Some(p) => writeln!(s, " protocol from env {p:?}"),
None => writeln!(s, " protocol from env <undecided, will query>"),
};
let ceiling = reply_ceiling();
let (t, m) = detect_measured();
let _ = match m {
Measured::NotNeeded => writeln!(
s,
" link round trip <not measured, environment sufficed>"
),
Measured::NoTty => writeln!(
s,
" link round trip <not measured, no terminal to ask>"
),
Measured::Link(Some(rtt)) => writeln!(
s,
" link round trip {}ms (replies given {}ms)",
rtt.as_millis(),
reply_budget(rtt, ceiling).as_millis(),
),
Measured::Link(None) => writeln!(
s,
" link round trip <silent for {}ms, gave up>",
ceiling.as_millis(),
),
};
let _ = writeln!(s, " cell size used {}x{}", t.cell.w, t.cell.h);
let _ = writeln!(s, " protocol {:?}", t.protocol);
s
}
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack.windows(needle.len()).position(|w| w == needle)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn local_terminals_keep_the_budget_they_always_had() {
let rtt = Duration::from_micros(200);
assert_eq!(reply_budget(rtt, LOCAL_CEILING), LOCAL_CEILING);
assert_eq!(reply_budget(rtt, REMOTE_CEILING), LOCAL_CEILING);
}
#[test]
fn a_slow_link_stretches_the_budget_but_not_past_the_ceiling() {
let atlantic = reply_budget(Duration::from_millis(90), REMOTE_CEILING);
assert_eq!(atlantic, Duration::from_millis(360));
let awful = reply_budget(Duration::from_millis(800), REMOTE_CEILING);
assert_eq!(awful, REMOTE_CEILING);
}
#[test]
fn the_ceiling_only_lifts_for_a_link_worth_waiting_on() {
assert!(REMOTE_CEILING > LOCAL_CEILING);
}
}