// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // 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); }