From 78076b53191d296925b720313637089f221bd6dd Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Thu, 25 Jun 2026 16:11:32 -0700 Subject: [PATCH] wire kitty chart into history cli command --- src/commands/history.zig | 112 ++++++++++++++++++++++++++++++++++----- src/term_graphics.zig | 16 +++--- 2 files changed, 107 insertions(+), 21 deletions(-) diff --git a/src/commands/history.zig b/src/commands/history.zig index f113a23..1de3074 100644 --- a/src/commands/history.zig +++ b/src/commands/history.zig @@ -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 `), 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 `). + 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 - )' 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); } diff --git a/src/term_graphics.zig b/src/term_graphics.zig index 54d4465..efe01bd 100644 --- a/src/term_graphics.zig +++ b/src/term_graphics.zig @@ -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);