use bevy::log::{info, warn};
use bevy::math::{UVec2, Vec2};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TermSize {
pub cols: u16,
pub rows: u16,
pub xpixel: u16,
pub ypixel: u16,
}
static CSI_PIXEL_SIZE: std::sync::OnceLock<Option<(u16, u16)>> = std::sync::OnceLock::new();
fn csi_cell_size(cols: u16, rows: u16) -> Option<(f32, f32)> {
let (w, h) = (*CSI_PIXEL_SIZE.get_or_init(query_pixel_size_via_csi))?;
if cols == 0 || rows == 0 || w == 0 || h == 0 {
return None;
}
Some((w as f32 / cols as f32, h as f32 / rows as f32))
}
fn query_pixel_size_via_csi() -> Option<(u16, u16)> {
use std::io::{Read, Write};
use std::os::fd::AsRawFd;
let mut tty = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open("/dev/tty")
.ok()?;
let fd = tty.as_raw_fd();
let mut saved: libc::termios = unsafe { std::mem::zeroed() };
if unsafe { libc::tcgetattr(fd, &mut saved) } != 0 {
return None;
}
let mut raw = saved;
unsafe { libc::cfmakeraw(&mut raw) };
raw.c_cc[libc::VMIN] = 0;
raw.c_cc[libc::VTIME] = 3;
if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &raw) } != 0 {
return None;
}
let result = (|| {
tty.write_all(b"\x1b[14t").ok()?;
tty.flush().ok()?;
let mut buf = Vec::with_capacity(32);
let mut byte = [0u8; 1];
for _ in 0..64 {
match tty.read(&mut byte) {
Ok(1) => {
buf.push(byte[0]);
if byte[0] == b't' {
break;
}
}
_ => break,
}
}
let s = String::from_utf8_lossy(&buf);
parse_csi_14t_reply(&s)
})();
unsafe { libc::tcsetattr(fd, libc::TCSANOW, &saved) };
result
}
fn parse_csi_14t_reply(s: &str) -> Option<(u16, u16)> {
let body = s.trim_start_matches('\x1b').trim_start_matches('[');
let body = body.strip_suffix('t').unwrap_or(body);
let mut parts = body.split(';');
if parts.next()? != "4" {
return None;
}
let h: u16 = parts.next()?.trim().parse().ok()?;
let w: u16 = parts.next()?.trim().parse().ok()?;
Some((w, h))
}
impl TermSize {
pub fn query(forced: Option<TermSize>) -> Self {
if let Some(ts) = forced {
return ts;
}
let mut ws: libc::winsize = unsafe { std::mem::zeroed() };
let rc = unsafe { libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &mut ws) };
if rc == 0 && ws.ws_col > 0 && ws.ws_row > 0 {
let mut xpixel = ws.ws_xpixel;
let mut ypixel = ws.ws_ypixel;
if xpixel == 0 || ypixel == 0 {
match csi_cell_size(ws.ws_col, ws.ws_row) {
Some((cw, chh)) => {
let w = (ws.ws_col as f32 * cw).round() as u16;
let h = (ws.ws_row as f32 * chh).round() as u16;
info!(
"[kitty] pixel size via CSI 14t: cell {cw:.2}x{chh:.2} -> {w}x{h} \
(grid {}x{})",
ws.ws_col, ws.ws_row
);
xpixel = w;
ypixel = h;
}
_ => {
warn!(
"[kitty] terminal reported no pixel size (cols={}, rows={}) and \
CSI 14t got no answer; assuming 8x16 px cells, so text may be \
mis-sized. Set KittyConfig::terminal_size to fix.",
ws.ws_col, ws.ws_row
);
xpixel = ws.ws_col.saturating_mul(8);
ypixel = ws.ws_row.saturating_mul(16);
}
}
}
return TermSize {
cols: ws.ws_col,
rows: ws.ws_row,
xpixel,
ypixel,
};
}
warn!("[kitty] TIOCGWINSZ failed (rc={rc}); falling back to 80x24 @ 640x384");
TermSize {
cols: 80,
rows: 24,
xpixel: 640,
ypixel: 384,
}
}
pub fn parse_spec(spec: &str) -> Option<Self> {
let (grid, px) = spec.split_once('@')?;
let (cols, rows) = grid.split_once('x')?;
let (w, h) = px.split_once('x')?;
Some(TermSize {
cols: cols.trim().parse().ok()?,
rows: rows.trim().parse().ok()?,
xpixel: w.trim().parse().ok()?,
ypixel: h.trim().parse().ok()?,
})
}
fn cell_px(&self) -> (u32, u32) {
let cw = if self.cols > 0 {
self.xpixel as u32 / self.cols as u32
} else {
8
};
let ch = if self.rows > 0 {
self.ypixel as u32 / self.rows as u32
} else {
16
};
(cw.max(1), ch.max(1))
}
}
#[derive(Clone, Copy, Debug)]
pub struct FitBox {
pub scale: f32,
pub ox: f32,
pub oy: f32,
pub cell_w: f32,
pub cell_h: f32,
pub virtual_size: UVec2,
}
impl FitBox {
pub fn compute(term: &TermSize, virtual_size: UVec2) -> Self {
let (cell_w, cell_h) = term.cell_px();
let avail_w = (term.cols as u32 * cell_w) as f32;
let avail_h = (term.rows as u32 * cell_h) as f32;
let vw = virtual_size.x.max(1) as f32;
let vh = virtual_size.y.max(1) as f32;
let scale = (avail_w / vw).min(avail_h / vh).max(0.01);
let used_w = vw * scale;
let used_h = vh * scale;
let ox = (avail_w - used_w).max(0.0) / 2.0;
let oy = (avail_h - used_h).max(0.0) / 2.0;
FitBox {
scale,
ox,
oy,
cell_w: cell_w as f32,
cell_h: cell_h as f32,
virtual_size,
}
}
pub fn map(&self, vx: f32, vy: f32) -> (u16, u16, u32, u32) {
let px = self.ox + vx * self.scale;
let py = self.oy + vy * self.scale;
let col = (px / self.cell_w).floor().max(0.0);
let row = (py / self.cell_h).floor().max(0.0);
let xoff = (px - col * self.cell_w).max(0.0) as u32;
let yoff = (py - row * self.cell_h).max(0.0) as u32;
((row as u32 + 1) as u16, (col as u32 + 1) as u16, xoff, yoff)
}
pub fn span_cells(&self, w: u32, h: u32) -> (u32, u32) {
let pw = w as f32 * self.scale;
let ph = h as f32 * self.scale;
(
(pw / self.cell_w).ceil().max(1.0) as u32,
(ph / self.cell_h).ceil().max(1.0) as u32,
)
}
pub fn cell_to_viewport(&self, col: u16, row: u16) -> Option<Vec2> {
let px = (col as f32 - 1.0 + 0.5) * self.cell_w;
let py = (row as f32 - 1.0 + 0.5) * self.cell_h;
let vx = (px - self.ox) / self.scale;
let vy = (py - self.oy) / self.scale;
if vx < 0.0
|| vy < 0.0
|| vx >= self.virtual_size.x as f32
|| vy >= self.virtual_size.y as f32
{
return None; }
Some(Vec2::new(vx, vy))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn real_term() -> TermSize {
TermSize {
cols: 212,
rows: 51,
xpixel: 1920,
ypixel: 1080,
}
}
const V: UVec2 = UVec2::new(320, 180);
#[test]
fn parses_a_full_spec() {
let ts = TermSize::parse_spec("212x51@1920x1080").unwrap();
assert_eq!(ts, real_term());
}
#[test]
fn rejects_malformed_specs() {
assert!(TermSize::parse_spec("212x51").is_none());
assert!(TermSize::parse_spec("212@1920x1080").is_none());
assert!(TermSize::parse_spec("").is_none());
assert!(TermSize::parse_spec("axb@cxd").is_none());
}
#[test]
fn csi_reply_is_height_then_width() {
assert_eq!(parse_csi_14t_reply("\x1b[4;1080;1920t"), Some((1920, 1080)));
assert_eq!(parse_csi_14t_reply("4;1080;1920"), Some((1920, 1080)));
assert_eq!(parse_csi_14t_reply("\x1b[6;51;212t"), None);
assert_eq!(parse_csi_14t_reply(""), None);
}
#[test]
fn fit_is_fractional_and_preserves_aspect() {
let fit = FitBox::compute(&real_term(), V);
assert_eq!(fit.cell_w, 9.0);
assert_eq!(fit.cell_h, 21.0);
assert!((fit.scale - 5.95).abs() < 1e-4, "scale {}", fit.scale);
assert!(fit.scale.fract() > 0.0, "scale should be fractional");
}
#[test]
fn the_far_edge_of_the_world_lands_on_a_cell_that_exists() {
let term = real_term();
let fit = FitBox::compute(&term, V);
let (row, col, _, _) = fit.map(V.x as f32 - 1.0, V.y as f32 - 1.0);
assert!(
col <= term.cols,
"col {col} exceeds the {}-column grid",
term.cols
);
assert!(
row <= term.rows,
"row {row} exceeds the {}-row grid",
term.rows
);
}
#[test]
fn fit_letterboxes_the_narrower_axis() {
let term = TermSize {
cols: 100,
rows: 50,
xpixel: 800,
ypixel: 600,
};
let fit = FitBox::compute(&term, V);
assert!((fit.scale - 2.5).abs() < 1e-4, "scale {}", fit.scale);
assert_eq!(fit.ox, 0.0);
assert!(fit.oy > 0.0, "expected vertical letterbox, got {}", fit.oy);
}
#[test]
fn map_then_cell_to_viewport_round_trips_within_a_cell() {
let fit = FitBox::compute(&real_term(), V);
let cell_vx = fit.cell_w / fit.scale;
let cell_vy = fit.cell_h / fit.scale;
for (vx, vy) in [(0.0, 0.0), (160.0, 90.0), (319.0, 179.0), (7.5, 133.25)] {
let (row, col, _, _) = fit.map(vx, vy);
let back = fit.cell_to_viewport(col, row).unwrap_or_else(|| {
panic!("({vx},{vy}) -> cell ({col},{row}) mapped outside world")
});
assert!(
(back.x - vx).abs() <= cell_vx && (back.y - vy).abs() <= cell_vy,
"({vx},{vy}) round-tripped to ({},{}) via cell ({col},{row})",
back.x,
back.y
);
}
}
#[test]
fn clicks_in_the_letterbox_are_rejected() {
let term = TermSize {
cols: 100,
rows: 50,
xpixel: 800,
ypixel: 600,
};
let fit = FitBox::compute(&term, V);
assert!(fit.cell_to_viewport(50, 1).is_none());
assert!(fit.cell_to_viewport(50, 25).is_some());
assert!(fit.cell_to_viewport(50, 50).is_none());
}
#[test]
fn map_is_one_based() {
let fit = FitBox::compute(&real_term(), V);
let (row, col, xoff, yoff) = fit.map(0.0, 0.0);
assert_eq!((row, col), (1, 1));
assert!(
(xoff as f32) < fit.cell_w,
"xoff {xoff} escapes the first cell"
);
assert!(
(yoff as f32) < fit.cell_h,
"yoff {yoff} escapes the first cell"
);
}
#[test]
fn an_exactly_divisible_terminal_has_no_padding_at_the_origin() {
let term = TermSize {
cols: 80,
rows: 24,
xpixel: 640,
ypixel: 360,
};
let fit = FitBox::compute(&term, V);
assert!((fit.scale - 2.0).abs() < 1e-4, "scale {}", fit.scale);
assert_eq!(fit.ox, 0.0);
let (row, col, xoff, yoff) = fit.map(0.0, 0.0);
assert_eq!((row, col, xoff), (1, 1, 0));
assert_eq!(yoff, 0);
}
#[test]
fn span_cells_never_returns_zero() {
let fit = FitBox::compute(&real_term(), V);
assert_eq!(fit.span_cells(0, 0), (1, 1));
let (c, r) = fit.span_cells(64, 64);
assert!(c >= 1 && r >= 1, "{c}x{r}");
}
#[test]
fn degenerate_virtual_size_does_not_divide_by_zero() {
let fit = FitBox::compute(&real_term(), UVec2::ZERO);
assert!(
fit.scale.is_finite() && fit.scale > 0.0,
"scale {}",
fit.scale
);
}
}