wire kitty chart into history cli command
This commit is contained in:
parent
6d47b46a5a
commit
78076b5319
2 changed files with 107 additions and 21 deletions
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue