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 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue