add kitty cli graphics infrastructure
This commit is contained in:
parent
889cfc215a
commit
6d47b46a5a
4 changed files with 277 additions and 0 deletions
|
|
@ -43,6 +43,8 @@
|
|||
const std = @import("std");
|
||||
const zfin = @import("../root.zig");
|
||||
const validator = @import("../comptime_validator.zig");
|
||||
const chart = @import("../charts/chart.zig");
|
||||
const term_query = @import("../term_query.zig");
|
||||
|
||||
// ── Group taxonomy ────────────────────────────────────────────
|
||||
|
||||
|
|
@ -206,6 +208,9 @@ pub const Globals = struct {
|
|||
watchlist_path: ?[]const u8 = null,
|
||||
/// Cache-freshness policy from `--refresh-data=<value>`.
|
||||
refresh_policy: RefreshPolicy = .auto,
|
||||
/// Chart graphics mode from `--chart` (auto / braille / WxH). Default
|
||||
/// is auto: kitty graphics when the terminal supports it, else braille.
|
||||
chart_config: chart.ChartConfig = .{},
|
||||
};
|
||||
|
||||
// ── RunCtx ────────────────────────────────────────────────────
|
||||
|
|
@ -250,6 +255,10 @@ pub const RunCtx = struct {
|
|||
now_s: i64,
|
||||
color: bool,
|
||||
out: *std.Io.Writer,
|
||||
/// Detected inline-graphics capability (kitty) + cell size, captured
|
||||
/// once at invocation entry. Commands consult this together with
|
||||
/// `globals.chart_config` to choose kitty-graphics vs braille output.
|
||||
graphics_caps: term_query.Caps = .{},
|
||||
|
||||
/// Resolve the portfolio pattern(s) (from `-p`/`--portfolio` or
|
||||
/// the default `portfolio*.srf` pattern) through cwd -> ZFIN_HOME.
|
||||
|
|
|
|||
15
src/main.zig
15
src/main.zig
|
|
@ -3,6 +3,8 @@ const zfin = @import("root.zig");
|
|||
const tui = @import("tui.zig");
|
||||
const cli = @import("commands/common.zig");
|
||||
const cmd_framework = @import("commands/framework.zig");
|
||||
const chart = @import("charts/chart.zig");
|
||||
const term_query = @import("term_query.zig");
|
||||
|
||||
/// Comptime registry of CLI commands. Field name is the user-facing
|
||||
/// subcommand name; value is the imported module struct. Order
|
||||
|
|
@ -159,6 +161,8 @@ const Globals = struct {
|
|||
/// Default: `.auto` (TTL-respecting). Other values: `.force`
|
||||
/// (re-fetch regardless of TTL) and `.never` (offline mode).
|
||||
refresh_policy: cmd_framework.RefreshPolicy = .auto,
|
||||
/// Chart graphics mode from `--chart` (auto / braille / WxH).
|
||||
chart_config: chart.ChartConfig = .{},
|
||||
/// Index into args of the first post-global token (the subcommand).
|
||||
cursor: usize,
|
||||
};
|
||||
|
|
@ -168,6 +172,8 @@ const GlobalParseError = error{
|
|||
UnknownGlobalFlag,
|
||||
/// `--refresh-data=<value>` got something other than auto/force/never.
|
||||
InvalidRefreshDataValue,
|
||||
/// `--chart=<value>` got something other than auto/braille/WxH.
|
||||
InvalidChartValue,
|
||||
/// Multiple `.srf` files appeared as a single -p argument, almost
|
||||
/// certainly because the shell expanded an unquoted glob. We
|
||||
/// surface this as a dedicated error so the user gets a friendly
|
||||
|
|
@ -284,6 +290,12 @@ fn parseGlobals(allocator: std.mem.Allocator, args: []const []const u8) GlobalPa
|
|||
// space). Surface the shape mismatch explicitly.
|
||||
return error.MissingValue;
|
||||
}
|
||||
if (std.mem.eql(u8, a, "--chart")) {
|
||||
if (i + 1 >= args.len) return error.MissingValue;
|
||||
g.chart_config = chart.ChartConfig.parse(args[i + 1]) orelse return error.InvalidChartValue;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
// Help flags are subcommand-like tokens, stop scanning.
|
||||
if (std.mem.eql(u8, a, "--help") or std.mem.eql(u8, a, "-h")) break;
|
||||
|
||||
|
|
@ -355,6 +367,7 @@ fn runCli(init: std.process.Init) !u8 {
|
|||
cli.stderrPrint(io, "\nRun 'zfin help' for usage.\n");
|
||||
},
|
||||
error.InvalidRefreshDataValue => cli.stderrPrint(io, "Error: --refresh-data=<value> requires one of: auto, force, never.\n"),
|
||||
error.InvalidChartValue => cli.stderrPrint(io, "Error: --chart requires one of: auto, braille, or WxH (e.g. 1920x1080).\n"),
|
||||
error.UnquotedGlobLikely => {
|
||||
cli.stderrPrint(io,
|
||||
\\Error: -p was given a single value followed by additional .srf files.
|
||||
|
|
@ -499,11 +512,13 @@ fn runCli(init: std.process.Init) !u8 {
|
|||
.portfolio_patterns = globals.portfolio_patterns,
|
||||
.watchlist_path = globals.watchlist_path,
|
||||
.refresh_policy = globals.refresh_policy,
|
||||
.chart_config = globals.chart_config,
|
||||
},
|
||||
.today = today,
|
||||
.now_s = now_s,
|
||||
.color = color,
|
||||
.out = out,
|
||||
.graphics_caps = term_query.detect(io, init.environ_map),
|
||||
};
|
||||
const dispatched_args = if (comptime Module.meta.uppercase_first_arg)
|
||||
try cmd_framework.normalizeFirstArg(allocator, cmd_args)
|
||||
|
|
|
|||
158
src/term_graphics.zig
Normal file
158
src/term_graphics.zig
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
//! Kitty graphics protocol emission for the plain CLI commands.
|
||||
//!
|
||||
//! The TUI renders bitmap charts through vaxis, which speaks the kitty
|
||||
//! graphics protocol for us. The non-interactive CLI (`zfin history`,
|
||||
//! `quote`, `projections`) has no vaxis loop, so it emits the protocol
|
||||
//! directly: this module turns a raw RGB buffer into the APC escape
|
||||
//! sequence that a kitty-capable terminal renders inline at the cursor.
|
||||
//!
|
||||
//! It also centralizes the inline-chart sizing: the on-screen size is
|
||||
//! expressed in terminal *columns* (the headline projections/quote
|
||||
//! charts are wider than the history timeline), and the pixel render
|
||||
//! size is derived from the terminal's cell pixel dimensions.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
/// Default cell pixel size assumed when the terminal hasn't reported one.
|
||||
/// Kitty scales the transmitted image to the requested `c`=cols,`r`=rows
|
||||
/// footprint, so an imperfect assumption only affects render crispness,
|
||||
/// not on-screen layout.
|
||||
pub const default_cell_w: u32 = 8;
|
||||
pub const default_cell_h: u32 = 16;
|
||||
|
||||
/// Inline-chart target widths in terminal columns. The headline charts
|
||||
/// (projections, quote) are wider than the history timeline; tuned to
|
||||
/// roughly match-and-exceed the braille chart's on-screen footprint.
|
||||
pub const history_cols: u16 = 80;
|
||||
pub const projection_cols: u16 = 120;
|
||||
pub const quote_cols: u16 = 120;
|
||||
|
||||
/// Maximum base64 payload bytes per APC escape, per the kitty protocol.
|
||||
const chunk_max: usize = 4096;
|
||||
|
||||
pub const PixelDims = struct { width: u32, height: u32 };
|
||||
|
||||
/// Pixel dimensions for a chart occupying `cols` x `rows` cells.
|
||||
pub fn pixelDims(cols: u16, rows: u16, cell_w: u32, cell_h: u32) PixelDims {
|
||||
return .{ .width = @as(u32, cols) * cell_w, .height = @as(u32, rows) * cell_h };
|
||||
}
|
||||
|
||||
/// Rows for a chart `cols` wide that preserve a ~3:1 width:height pixel
|
||||
/// aspect (matching the braille chart's proportions), given the cell
|
||||
/// pixel size. Always at least 1.
|
||||
pub fn rowsForWidth(cols: u16, cell_w: u32, cell_h: u32) u16 {
|
||||
const width_px = @as(u32, cols) * cell_w;
|
||||
const height_px = width_px / 3;
|
||||
const rows = (height_px + cell_h - 1) / cell_h; // round up
|
||||
return @intCast(@max(rows, @as(u32, 1)));
|
||||
}
|
||||
|
||||
/// Emit `rgb` (length must be `width_px * height_px * 3`) as a kitty
|
||||
/// graphics image displayed at the cursor, occupying `cols` x `rows`
|
||||
/// terminal cells. The payload is base64-encoded and split across as
|
||||
/// many APC escapes as needed (<= 4096 base64 bytes each). `q=2`
|
||||
/// suppresses the terminal's OK/error replies since the CLI isn't
|
||||
/// reading them back.
|
||||
///
|
||||
/// The cursor is left where the terminal put it (kitty does not move it
|
||||
/// past the image); callers should print `rows` newlines afterward to
|
||||
/// advance below the chart.
|
||||
pub fn emitKittyRGB(
|
||||
writer: *std.Io.Writer,
|
||||
alloc: std.mem.Allocator,
|
||||
rgb: []const u8,
|
||||
width_px: u32,
|
||||
height_px: u32,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
) !void {
|
||||
const Encoder = std.base64.standard.Encoder;
|
||||
const b64 = try alloc.alloc(u8, Encoder.calcSize(rgb.len));
|
||||
defer alloc.free(b64);
|
||||
_ = Encoder.encode(b64, rgb);
|
||||
|
||||
var off: usize = 0;
|
||||
var first = true;
|
||||
while (true) {
|
||||
const end = @min(off + chunk_max, b64.len);
|
||||
const more = end < b64.len;
|
||||
try writer.writeAll("\x1b_G");
|
||||
if (first) {
|
||||
// First escape carries the image metadata + control keys.
|
||||
try writer.print(
|
||||
"a=T,q=2,f=24,s={d},v={d},c={d},r={d},m={d}",
|
||||
.{ width_px, height_px, cols, rows, @intFromBool(more) },
|
||||
);
|
||||
first = false;
|
||||
} else {
|
||||
try writer.print("m={d}", .{@intFromBool(more)});
|
||||
}
|
||||
try writer.writeAll(";");
|
||||
try writer.writeAll(b64[off..end]);
|
||||
try writer.writeAll("\x1b\\");
|
||||
off = end;
|
||||
if (!more) break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "pixelDims multiplies cells by cell size" {
|
||||
const d = pixelDims(80, 14, 8, 16);
|
||||
try testing.expectEqual(@as(u32, 640), d.width);
|
||||
try testing.expectEqual(@as(u32, 224), d.height);
|
||||
}
|
||||
|
||||
test "rowsForWidth keeps a ~3:1 pixel aspect" {
|
||||
// 8x16 cells: rows ~= cols/6, rounded up.
|
||||
try testing.expectEqual(@as(u16, 14), rowsForWidth(80, 8, 16)); // 640/3/16 = 13.3 -> 14
|
||||
try testing.expectEqual(@as(u16, 20), rowsForWidth(120, 8, 16)); // 960/3/16 = 20
|
||||
// Never zero, even for a tiny width.
|
||||
try testing.expect(rowsForWidth(1, 8, 16) >= 1);
|
||||
}
|
||||
|
||||
test "emitKittyRGB: single chunk carries control keys + decodable payload" {
|
||||
const alloc = testing.allocator;
|
||||
var aw: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer aw.deinit();
|
||||
|
||||
const rgb = [_]u8{ 0xAA, 0xBB, 0xCC, 0x11, 0x22, 0x33 }; // 2x1 RGB
|
||||
try emitKittyRGB(&aw.writer, alloc, &rgb, 2, 1, 4, 2);
|
||||
const out = aw.written();
|
||||
|
||||
try testing.expect(std.mem.startsWith(u8, out, "\x1b_G"));
|
||||
try testing.expect(std.mem.endsWith(u8, out, "\x1b\\"));
|
||||
try testing.expect(std.mem.indexOf(u8, out, "a=T,q=2,f=24,s=2,v=1,c=4,r=2,m=0") != null);
|
||||
// Exactly one APC escape (one terminator).
|
||||
try testing.expectEqual(@as(usize, 1), std.mem.count(u8, out, "\x1b\\"));
|
||||
|
||||
// Payload (between ';' and the ST) round-trips back to the input.
|
||||
const semi = std.mem.indexOfScalar(u8, out, ';').?;
|
||||
const payload = out[semi + 1 .. out.len - 2];
|
||||
var decoded: [6]u8 = undefined;
|
||||
try std.base64.standard.Decoder.decode(&decoded, payload);
|
||||
try testing.expectEqualSlices(u8, &rgb, &decoded);
|
||||
}
|
||||
|
||||
test "emitKittyRGB: large payload is chunked with m=1 then a final m=0" {
|
||||
const alloc = testing.allocator;
|
||||
var aw: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer aw.deinit();
|
||||
|
||||
// 64x64 RGB = 12288 bytes -> base64 16384 bytes -> exactly 4 chunks.
|
||||
const rgb = try alloc.alloc(u8, 64 * 64 * 3);
|
||||
defer alloc.free(rgb);
|
||||
@memset(rgb, 0x7F);
|
||||
|
||||
try emitKittyRGB(&aw.writer, alloc, rgb, 64, 64, 10, 5);
|
||||
const out = aw.written();
|
||||
|
||||
try testing.expectEqual(@as(usize, 4), std.mem.count(u8, out, "\x1b_G"));
|
||||
// First escape: control keys + m=1 (more chunks follow).
|
||||
try testing.expect(std.mem.indexOf(u8, out, "a=T,q=2,f=24,s=64,v=64,c=10,r=5,m=1") != null);
|
||||
// A continuation escape with no control keys, and the final m=0.
|
||||
try testing.expect(std.mem.indexOf(u8, out, "\x1b_Gm=1;") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, out, "\x1b_Gm=0;") != null);
|
||||
}
|
||||
95
src/term_query.zig
Normal file
95
src/term_query.zig
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
//! Terminal capability probing for inline kitty graphics in the plain
|
||||
//! CLI commands (the TUI gets this from vaxis).
|
||||
//!
|
||||
//! v1 uses environment heuristics plus a TTY check; it does not actively
|
||||
//! query the terminal. The cell pixel size defaults to a common 8x16 -
|
||||
//! kitty scales the transmitted image to the requested cell footprint,
|
||||
//! so the default only affects render crispness, not layout. (An active
|
||||
//! `CSI 16 t` cell-size query is a sensible follow-up.)
|
||||
|
||||
const std = @import("std");
|
||||
const term_graphics = @import("term_graphics.zig");
|
||||
|
||||
pub const Caps = struct {
|
||||
/// The terminal appears to speak the kitty graphics protocol.
|
||||
kitty: bool = false,
|
||||
/// Cell pixel size used to size inline charts.
|
||||
cell_w: u32 = term_graphics.default_cell_w,
|
||||
cell_h: u32 = term_graphics.default_cell_h,
|
||||
};
|
||||
|
||||
/// Detect inline-graphics capability. Graphics escapes are only ever
|
||||
/// emitted to a real terminal, never into a pipe or file, so a non-TTY
|
||||
/// stdout always yields `.kitty = false` (callers fall back to braille).
|
||||
pub fn detect(io: std.Io, environ_map: *const std.process.Environ.Map) Caps {
|
||||
const is_tty = std.Io.File.stdout().isTty(io) catch false;
|
||||
if (!is_tty) return .{};
|
||||
return .{ .kitty = kittyFromEnv(environ_map) };
|
||||
}
|
||||
|
||||
/// Environment heuristic for kitty-graphics support. Recognizes kitty,
|
||||
/// Ghostty, and WezTerm - the common terminals implementing the
|
||||
/// protocol. (iTerm2 uses a different image protocol and is not matched.)
|
||||
pub fn kittyFromEnv(env: *const std.process.Environ.Map) bool {
|
||||
if (env.get("TERM")) |t| {
|
||||
if (std.mem.indexOf(u8, t, "kitty") != null) return true;
|
||||
}
|
||||
if (env.get("KITTY_WINDOW_ID") != null) return true;
|
||||
if (env.get("GHOSTTY_RESOURCES_DIR") != null) return true;
|
||||
if (env.get("GHOSTTY_BIN_DIR") != null) return true;
|
||||
if (env.get("WEZTERM_EXECUTABLE") != null) return true;
|
||||
if (env.get("WEZTERM_PANE") != null) return true;
|
||||
if (env.get("TERM_PROGRAM")) |p| {
|
||||
if (std.mem.eql(u8, p, "ghostty")) return true;
|
||||
if (std.mem.eql(u8, p, "WezTerm")) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "kittyFromEnv: kitty TERM" {
|
||||
var env = std.process.Environ.Map.init(testing.allocator);
|
||||
defer env.deinit();
|
||||
try env.put("TERM", "xterm-kitty");
|
||||
try testing.expect(kittyFromEnv(&env));
|
||||
}
|
||||
|
||||
test "kittyFromEnv: KITTY_WINDOW_ID, ghostty, wezterm" {
|
||||
{
|
||||
var env = std.process.Environ.Map.init(testing.allocator);
|
||||
defer env.deinit();
|
||||
try env.put("TERM", "xterm-256color");
|
||||
try env.put("KITTY_WINDOW_ID", "1");
|
||||
try testing.expect(kittyFromEnv(&env));
|
||||
}
|
||||
{
|
||||
var env = std.process.Environ.Map.init(testing.allocator);
|
||||
defer env.deinit();
|
||||
try env.put("TERM_PROGRAM", "ghostty");
|
||||
try testing.expect(kittyFromEnv(&env));
|
||||
}
|
||||
{
|
||||
var env = std.process.Environ.Map.init(testing.allocator);
|
||||
defer env.deinit();
|
||||
try env.put("WEZTERM_PANE", "0");
|
||||
try testing.expect(kittyFromEnv(&env));
|
||||
}
|
||||
}
|
||||
|
||||
test "kittyFromEnv: plain xterm / Apple Terminal is not kitty" {
|
||||
var env = std.process.Environ.Map.init(testing.allocator);
|
||||
defer env.deinit();
|
||||
try env.put("TERM", "xterm-256color");
|
||||
try env.put("TERM_PROGRAM", "Apple_Terminal");
|
||||
try testing.expect(!kittyFromEnv(&env));
|
||||
}
|
||||
|
||||
test "Caps defaults to the assumed cell size, no kitty" {
|
||||
const c: Caps = .{};
|
||||
try testing.expect(!c.kitty);
|
||||
try testing.expectEqual(term_graphics.default_cell_w, c.cell_w);
|
||||
try testing.expectEqual(term_graphics.default_cell_h, c.cell_h);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue