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 view = @import("../views/history.zig");
const chart_export = @import("../chart_export.zig"); const chart_export = @import("../chart_export.zig");
const line_chart = @import("../charts/line_chart.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 fmt = cli.fmt;
const Date = @import("../Date.zig"); const Date = @import("../Date.zig");
@ -220,7 +224,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
.portfolio => |opts| { .portfolio => |opts| {
const pf = ctx.resolvePortfolioPath(); const pf = ctx.resolvePortfolioPath();
defer pf.deinit(ctx.allocator); 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, opts: PortfolioOpts,
color: bool, color: bool,
out: *std.Io.Writer, out: *std.Io.Writer,
chart_config: chart.ChartConfig,
caps: term_query.Caps,
) !void { ) !void {
var tl = try history.loadTimeline(io, allocator, portfolio_path); var tl = try history.loadTimeline(io, allocator, portfolio_path);
defer tl.deinit(); defer tl.deinit();
@ -348,7 +354,18 @@ fn runPortfolio(
timeline.selectResolution(filtered) timeline.selectResolution(filtered)
else else
.cascading; .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 /// Regenerate `history/rollup.srf` from `snapshots`. Uses
@ -414,6 +431,7 @@ pub fn renderPortfolio(
resolution: timeline.Resolution, resolution: timeline.Resolution,
resolution_override: ?timeline.Resolution, resolution_override: ?timeline.Resolution,
row_limit: usize, row_limit: usize,
chart_render: ChartRender,
) !void { ) !void {
try cli.printBold(out, color, "\nPortfolio Timeline: {s}\n", .{focus_metric.label()}); try cli.printBold(out, color, "\nPortfolio Timeline: {s}\n", .{focus_metric.label()});
try out.print("========================================\n", .{}); try out.print("========================================\n", .{});
@ -424,9 +442,9 @@ pub fn renderPortfolio(
defer ws.deinit(); defer ws.deinit();
try renderWindowsBlock(out, color, ws); try renderWindowsBlock(out, color, ws);
// Chart (synthetic candles from focused-metric values) // Chart (inline kitty graphics or braille fallback)
try out.print("\n", .{}); try out.print("\n", .{});
try renderBrailleChart(allocator, out, color, points, focus_metric); try renderTimelineChart(allocator, out, color, points, focus_metric, chart_render);
// Table // Table
if (resolution == .cascading) { if (resolution == .cascading) {
@ -538,7 +556,73 @@ fn exportMetricChart(
try chart_export.exportTimelineChart(io, allocator, lps, baseline, path); 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, allocator: std.mem.Allocator,
out: *std.Io.Writer, out: *std.Io.Writer,
color: bool, color: bool,
@ -572,9 +656,9 @@ fn renderBrailleChart(
} }
const candles = candles_list.items; const candles = candles_list.items;
var chart = fmt.computeBrailleChart(allocator, candles, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return; var braille_chart = fmt.computeBrailleChart(allocator, candles, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return;
defer chart.deinit(allocator); defer braille_chart.deinit(allocator);
try fmt.writeBrailleAnsi(out, &chart, color, cli.CLR_MUTED, false); try fmt.writeBrailleAnsi(out, &braille_chart, color, cli.CLR_MUTED, false);
} }
fn renderTable( 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, 18, 750, 350, 1100),
makeTimelinePoint(2026, 4, 21, 800, 400, 1200), 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(); const out = w.buffered();
// Header // Header
@ -959,7 +1043,7 @@ test "renderPortfolio: auto resolution shows '(auto - <effective>)' label" {
makeTimelinePoint(2026, 4, 18, 750, 350, 1100), makeTimelinePoint(2026, 4, 18, 750, 350, 1100),
}; };
// resolution_override = null -> auto. Effective is daily (span 90d). // 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(); const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "(auto - daily)") != null); 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, 17, 700, 300, 1000),
makeTimelinePoint(2026, 4, 18, 750, 350, 1100), 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); 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{ const pts = [_]timeline.TimelinePoint{
makeTimelinePoint(2026, 4, 17, 700, 300, 1000), 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(); const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "2026-04-17") != null); try testing.expect(std.mem.indexOf(u8, out, "2026-04-17") != null);
// Chart requires >= 2 points; confirm no crash, table shows one row. // 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, 20, 770, 370, 1140),
makeTimelinePoint(2026, 4, 21, 780, 380, 1160), 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(); const out = w.buffered();
// 5 snapshots total, 2 shown. // 5 snapshots total, 2 shown.
try testing.expect(std.mem.indexOf(u8, out, "5 snapshots") != null); 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, 3, 31, 800, 400, 1200),
makeTimelinePoint(2026, 4, 21, 900, 500, 1400), 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(); const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "(monthly)") != null); 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 /// terminal cells. The payload is base64-encoded and split across as
/// many APC escapes as needed (<= 4096 base64 bytes each). `q=2` /// many APC escapes as needed (<= 4096 base64 bytes each). `q=2`
/// suppresses the terminal's OK/error replies since the CLI isn't /// 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 /// Because of `C=1` the cursor stays at the image's top-left, so callers
/// past the image); callers should print `rows` newlines afterward to /// MUST print `rows` newlines afterward to advance below the chart. This
/// advance below the chart. /// makes the vertical advance deterministic across terminals (rather
/// than relying on each terminal's default cursor-movement policy).
pub fn emitKittyRGB( pub fn emitKittyRGB(
writer: *std.Io.Writer, writer: *std.Io.Writer,
alloc: std.mem.Allocator, alloc: std.mem.Allocator,
@ -80,7 +82,7 @@ pub fn emitKittyRGB(
if (first) { if (first) {
// First escape carries the image metadata + control keys. // First escape carries the image metadata + control keys.
try writer.print( 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) }, .{ width_px, height_px, cols, rows, @intFromBool(more) },
); );
first = false; 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.startsWith(u8, out, "\x1b_G"));
try testing.expect(std.mem.endsWith(u8, out, "\x1b\\")); 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). // Exactly one APC escape (one terminator).
try testing.expectEqual(@as(usize, 1), std.mem.count(u8, out, "\x1b\\")); 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")); try testing.expectEqual(@as(usize, 4), std.mem.count(u8, out, "\x1b_G"));
// First escape: control keys + m=1 (more chunks follow). // 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. // 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=1;") != null);
try testing.expect(std.mem.indexOf(u8, out, "\x1b_Gm=0;") != null); try testing.expect(std.mem.indexOf(u8, out, "\x1b_Gm=0;") != null);