diff --git a/src/commands/framework.zig b/src/commands/framework.zig index 7edd720..cbb7485 100644 --- a/src/commands/framework.zig +++ b/src/commands/framework.zig @@ -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=`. 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. diff --git a/src/main.zig b/src/main.zig index 6526c12..dee9808 100644 --- a/src/main.zig +++ b/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=` got something other than auto/force/never. InvalidRefreshDataValue, + /// `--chart=` 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= 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) diff --git a/src/term_graphics.zig b/src/term_graphics.zig new file mode 100644 index 0000000..54d4465 --- /dev/null +++ b/src/term_graphics.zig @@ -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); +} diff --git a/src/term_query.zig b/src/term_query.zig new file mode 100644 index 0000000..b71e646 --- /dev/null +++ b/src/term_query.zig @@ -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); +}