Something went wrong. Try again.
A rainbow login banner drawn in a real font and shown with the kitty graphics protocol, written in Zig.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Asking the terminal what it can do, before anything is drawn.//!//! Five queries go out at once: the kitty graphics support query, the cell//! size in pixels (`CSI 16 t`), the text area in pixels (`CSI 14 t`) and in//! cells (`CSI 18 t`), and last the primary device attributes (`CSI c`).//! Every terminal answers that last one, even one that ignores the rest, and//! answers in order, so its reply means every other reply that is coming//! has come. A short timeout is the backstop for a terminal that answers//! nothing at all.
const std = @import("std");const Io = std.Io;const posix = std.posix;
/// The queries, in the order they are sent. The graphics query transmits a/// one-pixel image under an id with `a=q`, which asks the terminal to check/// it and reply without keeping it.pub const queries = "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\" ++ "\x1b[16t" ++ "\x1b[14t" ++ "\x1b[18t" ++ "\x1b[c";
/// What the terminal said.pub const Replies = struct { /// It answered the graphics query with `OK`. graphics: bool = false, /// One cell, in pixels, from `CSI 16 t`. cell_width: ?u32 = null, cell_height: ?u32 = null, /// The text area, in pixels, from `CSI 14 t`. width_px: ?u32 = null, height_px: ?u32 = null, /// The text area, in cells, from `CSI 18 t`. columns: ?u32 = null, rows: ?u32 = null, /// The device attributes have answered, so nothing else is coming. done: bool = false,
/// The height of one cell in pixels: as reported, or else the text /// area's height shared among its rows. pub fn cellHeight(r: Replies) ?u32 { if (r.cell_height) |h| if (h > 0) return h; const px = r.height_px orelse return null; const rows = r.rows orelse return null; if (rows == 0 or px < rows) return null; return px / rows; }
/// The width of the text area in pixels: as reported, or else the cell /// width times the number of columns. pub fn widthPx(r: Replies) ?u32 { if (r.width_px) |w| if (w > 0) return w; const cw = r.cell_width orelse return null; const cols = r.columns orelse return null; if (cw == 0 or cols == 0) return null; return cw * cols; }};
/// Reads the replies out of everything the terminal has sent so far. Bytes/// that are not a reply — a key typed while waiting, say — are skipped, and/// a reply cut off at the end of `bytes` is left for when the rest arrives.pub fn parse(bytes: []const u8) Replies { var r: Replies = .{}; var i: usize = 0; while (i < bytes.len) { if (bytes[i] != 0x1b or i + 1 >= bytes.len) { i += 1; continue; } switch (bytes[i + 1]) { '_' => { // An APC, ended by ST. const body_start = i + 2; const end = std.mem.find(u8, bytes[body_start..], "\x1b\\") orelse break; const body = bytes[body_start..][0..end]; if (std.mem.startsWith(u8, body, "G")) { if (std.mem.findScalar(u8, body, ';')) |semi| { if (std.mem.startsWith(u8, body[semi + 1 ..], "OK")) r.graphics = true; } } i = body_start + end + 2; }, '[' => { // A CSI: parameter and intermediate bytes, then a final byte. var j = i + 2; while (j < bytes.len and (bytes[j] < 0x40 or bytes[j] > 0x7e)) j += 1; if (j >= bytes.len) break; const params = bytes[i + 2 .. j]; switch (bytes[j]) { 'c' => if (std.mem.startsWith(u8, params, "?")) { r.done = true; }, 't' => sizeReport(&r, params), else => {}, } i = j + 1; }, else => i += 1, } } return r;}
fn sizeReport(r: *Replies, params: []const u8) void { var it = std.mem.splitScalar(u8, params, ';'); const kind = it.next() orelse return; const a = std.fmt.parseInt(u32, it.next() orelse return, 10) catch return; const b = std.fmt.parseInt(u32, it.next() orelse return, 10) catch return; if (std.mem.eql(u8, kind, "6")) { r.cell_height = a; r.cell_width = b; } else if (std.mem.eql(u8, kind, "4")) { r.height_px = a; r.width_px = b; } else if (std.mem.eql(u8, kind, "8")) { r.rows = a; r.columns = b; }}
/// Asks the terminal on standard input and output, waiting at most/// `timeout_ms` for it to finish answering. Null when either is not a/// terminal, or when the terminal finishes nothing in time: there is then/// nobody to draw for.pub fn probe(io: Io, timeout_ms: u32) !?Replies { const stdin: Io.File = .stdin(); const stdout: Io.File = .stdout(); if (!try stdin.isTty(io) or !try stdout.isTty(io)) return null;
const fd = stdin.handle; const saved = posix.tcgetattr(fd) catch return null; var raw = saved; // No line editing and no echo, so the replies arrive as they come and // are not printed back at the user; output processing is left alone. raw.lflag.ICANON = false; raw.lflag.ECHO = false; raw.cc[@backingInt(posix.V.MIN)] = 0; raw.cc[@backingInt(posix.V.TIME)] = 0; posix.tcsetattr(fd, .NOW, raw) catch return null; defer posix.tcsetattr(fd, .NOW, saved) catch {};
var out_buf: [128]u8 = undefined; var out = stdout.writer(io, &out_buf); out.interface.writeAll(queries) catch return null; out.interface.flush() catch return null;
var buf: [1024]u8 = undefined; var len: usize = 0; const deadline = Io.Timestamp.now(io, .awake).addDuration(.fromMilliseconds(timeout_ms)); while (true) { const left = Io.Timestamp.now(io, .awake).durationTo(deadline).toMilliseconds(); if (left <= 0) return null; var fds = [_]posix.pollfd{.{ .fd = fd, .events = posix.POLL.IN, .revents = 0 }}; const ready = posix.poll(&fds, @intCast(left)) catch return null; if (ready == 0) return null; if (len == buf.len) return null; const n = posix.read(fd, buf[len..]) catch return null; if (n == 0) return null; len += n; const r = parse(buf[0..len]); if (r.done) return r; }}
const testing = std.testing;
test "kitty with every reply" { const r = parse("\x1b_Gi=31;OK\x1b\\\x1b[6;20;10t\x1b[4;480;800t\x1b[8;24;80t\x1b[?62;22;52c"); try testing.expect(r.graphics); try testing.expect(r.done); try testing.expectEqual(@as(?u32, 20), r.cell_height); try testing.expectEqual(@as(?u32, 10), r.cell_width); try testing.expectEqual(@as(?u32, 800), r.widthPx()); try testing.expectEqual(@as(?u32, 20), r.cellHeight());}
test "a terminal without graphics answers only the attributes" { const r = parse("\x1b[?1;2c"); try testing.expect(!r.graphics); try testing.expect(r.done); try testing.expectEqual(@as(?u32, null), r.cellHeight());}
test "a graphics error is not support" { const r = parse("\x1b_Gi=31;ENOTSUPPORTED:no\x1b\\\x1b[?62c"); try testing.expect(!r.graphics); try testing.expect(r.done);}
test "cell size from the text area when CSI 16 t goes unanswered" { const r = parse("\x1b_Gi=31;OK\x1b\\\x1b[4;480;800t\x1b[8;24;80t\x1b[?62c"); try testing.expectEqual(@as(?u32, 20), r.cellHeight()); try testing.expectEqual(@as(?u32, 800), r.widthPx());}
test "width from cells when CSI 14 t goes unanswered" { const r = parse("\x1b[6;20;10t\x1b[8;24;80t\x1b[?62c"); try testing.expectEqual(@as(?u32, 800), r.widthPx());}
test "a reply cut off is not finished" { try testing.expect(!parse("\x1b_Gi=31;OK\x1b\\\x1b[?62;2").done); try testing.expect(!parse("\x1b_Gi=31;O").graphics);}
test "typed keys among the replies are skipped" { const r = parse("ls\x1b_Gi=31;OK\x1b\\x\x1b[A\x1b[6;16;8t\x1b[?6c"); try testing.expect(r.graphics); try testing.expect(r.done); try testing.expectEqual(@as(?u32, 16), r.cell_height);}