wire kitty chart into history cli command

This commit is contained in:
Emil Lerch 2026-06-25 16:11:32 -07:00
parent 6d47b46a5a
commit 78076b5319
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 107 additions and 21 deletions

View file

@ -44,6 +44,10 @@ const snapshot_model = @import("../models/snapshot.zig");
const view = @import("../views/history.zig");
const chart_export = @import("../chart_export.zig");
const line_chart = @import("../charts/line_chart.zig");
const chart = @import("../charts/chart.zig");
const term_graphics = @import("../term_graphics.zig");
const term_query = @import("../term_query.zig");
const theme = @import("../tui/theme.zig");
const fmt = cli.fmt;
const Date = @import("../Date.zig");
@ -220,7 +224,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
.portfolio => |opts| {
const pf = ctx.resolvePortfolioPath();
defer pf.deinit(ctx.allocator);
try runPortfolio(ctx.io, ctx.allocator, pf.path, opts, ctx.color, ctx.out);
try runPortfolio(ctx.io, ctx.allocator, pf.path, opts, ctx.color, ctx.out, ctx.globals.chart_config, ctx.graphics_caps);
},
}
}
@ -290,6 +294,8 @@ fn runPortfolio(
opts: PortfolioOpts,
color: bool,
out: *std.Io.Writer,
chart_config: chart.ChartConfig,
caps: term_query.Caps,
) !void {
var tl = try history.loadTimeline(io, allocator, portfolio_path);
defer tl.deinit();
@ -348,7 +354,18 @@ fn runPortfolio(
timeline.selectResolution(filtered)
else
.cascading;
try renderPortfolio(allocator, out, color, filtered, opts.metric, resolution, opts.resolution, opts.limit orelse 40);
// Resolve how to draw the inline chart: kitty graphics when the
// terminal supports it (or it's forced via `--chart <WxH>`), else
// braille. `--chart braille` always forces braille.
const k: KittyChart = .{ .io = io, .caps = caps, .baseline = opts.baseline };
const chart_render: ChartRender = switch (chart_config.mode) {
.braille => .braille,
.kitty => .{ .kitty = k },
.auto => if (caps.kitty) .{ .kitty = k } else .braille,
};
try renderPortfolio(allocator, out, color, filtered, opts.metric, resolution, opts.resolution, opts.limit orelse 40, chart_render);
}
/// Regenerate `history/rollup.srf` from `snapshots`. Uses
@ -414,6 +431,7 @@ pub fn renderPortfolio(
resolution: timeline.Resolution,
resolution_override: ?timeline.Resolution,
row_limit: usize,
chart_render: ChartRender,
) !void {
try cli.printBold(out, color, "\nPortfolio Timeline: {s}\n", .{focus_metric.label()});
try out.print("========================================\n", .{});
@ -424,9 +442,9 @@ pub fn renderPortfolio(
defer ws.deinit();
try renderWindowsBlock(out, color, ws);
// Chart (synthetic candles from focused-metric values)
// Chart (inline kitty graphics or braille fallback)
try out.print("\n", .{});
try renderBrailleChart(allocator, out, color, points, focus_metric);
try renderTimelineChart(allocator, out, color, points, focus_metric, chart_render);
// Table
if (resolution == .cascading) {
@ -538,7 +556,73 @@ fn exportMetricChart(
try chart_export.exportTimelineChart(io, allocator, lps, baseline, path);
}
fn renderBrailleChart(
/// How `renderPortfolio` draws the timeline chart.
const ChartRender = union(enum) {
/// Terminal-agnostic braille - the universal fallback.
braille,
/// Inline kitty graphics, for capable terminals (or forced via
/// `--chart <WxH>`).
kitty: KittyChart,
};
const KittyChart = struct {
io: std.Io,
caps: term_query.Caps,
baseline: line_chart.Baseline,
};
/// Draw the focused-metric timeline. Dispatches to inline kitty graphics
/// or braille; a kitty render that can't produce a bitmap (too few
/// points) falls back to braille rather than drawing nothing.
fn renderTimelineChart(
allocator: std.mem.Allocator,
out: *std.Io.Writer,
color: bool,
points: []const timeline.TimelinePoint,
metric: timeline.Metric,
render: ChartRender,
) !void {
switch (render) {
.braille => try renderBraille(allocator, out, color, points, metric),
.kitty => |k| emitTimelineKitty(allocator, out, points, metric, k) catch |err| switch (err) {
error.InsufficientData => try renderBraille(allocator, out, color, points, metric),
else => return err,
},
}
}
/// Render the timeline as a kitty-graphics line chart (labeled, sized to
/// `term_graphics.history_cols`) and emit it at the cursor, advancing
/// below it. Returns `error.InsufficientData` when there aren't enough
/// points to draw, so the caller can fall back to braille.
fn emitTimelineKitty(
allocator: std.mem.Allocator,
out: *std.Io.Writer,
points: []const timeline.TimelinePoint,
metric: timeline.Metric,
k: KittyChart,
) !void {
const series = try timeline.extractChartSeries(allocator, points, metric);
defer allocator.free(series);
if (series.len < 2) return error.InsufficientData;
const lps = try metricLinePoints(allocator, series);
defer allocator.free(lps);
const cols = term_graphics.history_cols;
const rows = term_graphics.rowsForWidth(cols, k.caps.cell_w, k.caps.cell_h);
const dims = term_graphics.pixelDims(cols, rows, k.caps.cell_w, k.caps.cell_h);
var rendered = try line_chart.renderToSurface(k.io, allocator, lps, dims.width, dims.height, theme.default_theme, .{ .baseline = k.baseline, .axis_labels = true });
defer rendered.deinit(allocator);
const rgb = try rendered.extractRgb(allocator);
defer allocator.free(rgb);
try term_graphics.emitKittyRGB(out, allocator, rgb, dims.width, dims.height, cols, rows);
// Kitty leaves the cursor in place; advance below the placed image.
for (0..rows) |_| try out.writeByte('\n');
}
fn renderBraille(
allocator: std.mem.Allocator,
out: *std.Io.Writer,
color: bool,
@ -572,9 +656,9 @@ fn renderBrailleChart(
}
const candles = candles_list.items;
var chart = fmt.computeBrailleChart(allocator, candles, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return;
defer chart.deinit(allocator);
try fmt.writeBrailleAnsi(out, &chart, color, cli.CLR_MUTED, false);
var braille_chart = fmt.computeBrailleChart(allocator, candles, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return;
defer braille_chart.deinit(allocator);
try fmt.writeBrailleAnsi(out, &braille_chart, color, cli.CLR_MUTED, false);
}
fn renderTable(
@ -911,7 +995,7 @@ test "renderPortfolio: shows header, windows block, chart, and table" {
makeTimelinePoint(2026, 4, 18, 750, 350, 1100),
makeTimelinePoint(2026, 4, 21, 800, 400, 1200),
};
try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, .daily, 40);
try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, .daily, 40, .braille);
const out = w.buffered();
// Header
@ -959,7 +1043,7 @@ test "renderPortfolio: auto resolution shows '(auto - <effective>)' label" {
makeTimelinePoint(2026, 4, 18, 750, 350, 1100),
};
// resolution_override = null -> auto. Effective is daily (span 90d).
try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, null, 40);
try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, null, 40, .braille);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "(auto - daily)") != null);
}
@ -971,7 +1055,7 @@ test "renderPortfolio: color mode emits ANSI" {
makeTimelinePoint(2026, 4, 17, 700, 300, 1000),
makeTimelinePoint(2026, 4, 18, 750, 350, 1100),
};
try renderPortfolio(testing.allocator, &w, true, &pts, .liquid, .daily, .daily, 40);
try renderPortfolio(testing.allocator, &w, true, &pts, .liquid, .daily, .daily, 40, .braille);
try testing.expect(std.mem.indexOf(u8, w.buffered(), "\x1b[") != null);
}
@ -981,7 +1065,7 @@ test "renderPortfolio: single point renders without crashing" {
const pts = [_]timeline.TimelinePoint{
makeTimelinePoint(2026, 4, 17, 700, 300, 1000),
};
try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, .daily, 40);
try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, .daily, 40, .braille);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "2026-04-17") != null);
// Chart requires >= 2 points; confirm no crash, table shows one row.
@ -1000,7 +1084,7 @@ test "renderPortfolio: row_limit caps table rows" {
makeTimelinePoint(2026, 4, 20, 770, 370, 1140),
makeTimelinePoint(2026, 4, 21, 780, 380, 1160),
};
try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, .daily, 2);
try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, .daily, 2, .braille);
const out = w.buffered();
// 5 snapshots total, 2 shown.
try testing.expect(std.mem.indexOf(u8, out, "5 snapshots") != null);
@ -1019,7 +1103,7 @@ test "renderPortfolio: monthly resolution labels the table accordingly" {
makeTimelinePoint(2026, 3, 31, 800, 400, 1200),
makeTimelinePoint(2026, 4, 21, 900, 500, 1400),
};
try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .monthly, .monthly, 40);
try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .monthly, .monthly, 40, .braille);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "(monthly)") != null);
}

View file

@ -52,11 +52,13 @@ pub fn rowsForWidth(cols: u16, cell_w: u32, cell_h: u32) u16 {
/// 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.
/// reading them back; `C=1` keeps the terminal from moving the cursor
/// when the image is placed.
///
/// 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.
/// Because of `C=1` the cursor stays at the image's top-left, so callers
/// MUST print `rows` newlines afterward to advance below the chart. This
/// makes the vertical advance deterministic across terminals (rather
/// than relying on each terminal's default cursor-movement policy).
pub fn emitKittyRGB(
writer: *std.Io.Writer,
alloc: std.mem.Allocator,
@ -80,7 +82,7 @@ pub fn emitKittyRGB(
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}",
"a=T,q=2,C=1,f=24,s={d},v={d},c={d},r={d},m={d}",
.{ width_px, height_px, cols, rows, @intFromBool(more) },
);
first = false;
@ -124,7 +126,7 @@ test "emitKittyRGB: single chunk carries control keys + decodable payload" {
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);
try testing.expect(std.mem.indexOf(u8, out, "a=T,q=2,C=1,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\\"));
@ -151,7 +153,7 @@ test "emitKittyRGB: large payload is chunked with m=1 then a final m=0" {
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);
try testing.expect(std.mem.indexOf(u8, out, "a=T,q=2,C=1,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);