add ADDITIONAL kitty-based projections chart to the top of the projections command if terminal supports

This commit is contained in:
Emil Lerch 2026-06-25 16:44:15 -07:00
parent 692a28aed7
commit dd550a85d9
Signed by: lobo
GPG key ID: A7B62D657EF764F8

View file

@ -22,6 +22,10 @@ const forecast = @import("../analytics/forecast_evaluation.zig");
const milestones = @import("../analytics/milestones.zig");
const shiller = @import("../data/shiller.zig");
const chart_export = @import("../chart_export.zig");
const projection_chart = @import("../charts/projection_chart.zig");
const term_graphics = @import("../term_graphics.zig");
const term_query = @import("../term_query.zig");
const theme = @import("../tui/theme.zig");
/// Tagged-union args for the four projection sub-modes. Mutually-
/// exclusive flag combos (--convergence with --vs, --real with
@ -279,6 +283,14 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
// total. Snapshot-only as-of paths ignore it.
var live = try loadLiveData(ctx, today, color);
defer if (live) |*l| l.deinit(allocator);
// Inline kitty band chart when supported (or forced). There's
// no braille fallback for projections - non-kitty terminals
// keep the table-only output.
const kitty_caps: ?term_query.Caps = switch (ctx.globals.chart_config.mode) {
.braille => null,
.kitty => ctx.graphics_caps,
.auto => if (ctx.graphics_caps.kitty) ctx.graphics_caps else null,
};
try runBands(
io,
allocator,
@ -295,6 +307,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
},
color,
out,
kitty_caps,
);
},
}
@ -541,6 +554,42 @@ pub fn anyImportedOnly(
return now_res.source == .imported;
}
/// Render the percentile-band chart (longest horizon, with the actuals
/// overlay when present) as kitty graphics at `term_graphics.projection_cols`
/// wide and emit it inline. Returns `error.InsufficientData` when bands
/// aren't available so the caller can skip the chart - projections has
/// no braille fallback. All allocations come from the arena `va`.
fn emitBandsKitty(
io: std.Io,
va: std.mem.Allocator,
ctx: *const view.ProjectionContext,
caps: term_query.Caps,
out: *std.Io.Writer,
) !void {
const horizons = ctx.config.getHorizons();
if (horizons.len == 0) return error.InsufficientData;
const bands_ec = ctx.data.bands[horizons.len - 1] orelse return error.InsufficientData;
// Translate the view-layer overlay (if any) into the chart module's
// ActualsPoint shape - same conversion as the PNG export path. The
// arena owns the buffer; it lives as long as `overlay_input`.
const overlay_input = blk: {
const ov = ctx.overlay_actuals orelse break :blk @as(?projection_chart.ActualsOverlay, null);
const buf = va.alloc(projection_chart.ActualsPoint, ov.points.len) catch break :blk @as(?projection_chart.ActualsOverlay, null);
for (ov.points, 0..) |p, i| buf[i] = .{ .years_from_as_of = p.years_from_as_of, .liquid = p.liquid };
break :blk projection_chart.ActualsOverlay{ .points = buf, .today_years = ov.today_years };
};
const cols = term_graphics.projection_cols;
const rows = term_graphics.rowsForWidth(cols, caps.cell_w, caps.cell_h);
const dims = term_graphics.pixelDims(cols, rows, caps.cell_w, caps.cell_h);
var rendered = try projection_chart.renderToSurface(io, va, bands_ec, dims.width, dims.height, theme.default_theme, overlay_input, true);
defer rendered.deinit(va);
const rgb = try rendered.extractRgb(va);
try term_graphics.placeInline(out, va, rgb, dims.width, dims.height, cols, rows);
}
pub fn runBands(
io: std.Io,
allocator: std.mem.Allocator,
@ -549,6 +598,7 @@ pub fn runBands(
opts: BandsOptions,
color: bool,
out: *std.Io.Writer,
kitty_caps: ?term_query.Caps,
) !void {
// Single arena for all view/render allocations. Same lifetime
// regardless of live vs. as-of path.
@ -709,6 +759,17 @@ pub fn runBands(
}
try out.print("========================================\n", .{});
// Headline percentile-band chart, inline via kitty graphics when
// supported (or forced). No braille fallback - non-kitty terminals
// keep the table-only view below.
if (kitty_caps) |kc| {
try out.print("\n", .{});
emitBandsKitty(io, va, &ctx, kc, out) catch |err| switch (err) {
error.InsufficientData => {}, // no bands yet; fall through to the table
else => return err,
};
}
// If auto-snapped, print a muted note so the user knows the
// requested date wasn't an exact hit. The wording reflects the
// resolution source - "nearest snapshot" vs "nearest imported
@ -1902,7 +1963,7 @@ test "runBands: imported-only as_of scales today's composition and renders body"
.today = today,
.overlay_actuals = false,
.live = &ld,
}, false, &stream);
}, false, &stream, null);
const out = stream.buffered();
// Header reflects the imported source, and the caveat explains
@ -1936,7 +1997,7 @@ test "runBands: imported-only as_of without live data returns cleanly" {
.today = Date.fromYmd(2026, 3, 13),
.overlay_actuals = false,
.live = null,
}, false, &stream);
}, false, &stream, null);
// The helper printed a clear stderr message (swallowed by
// cli.stderrPrint) and returned without body output.
@ -2129,7 +2190,7 @@ test "run: as_of with no snapshots returns without error (stderr-only)" {
var stream = std.Io.Writer.fixed(&buf);
const d = Date.fromYmd(2026, 3, 13);
try runBands(io, testing.allocator, &svc, pf, .{ .events_enabled = false, .as_of = d, .from_snapshot = true, .today = d, .overlay_actuals = false }, false, &stream);
try runBands(io, testing.allocator, &svc, pf, .{ .events_enabled = false, .as_of = d, .from_snapshot = true, .today = d, .overlay_actuals = false }, false, &stream, null);
// No body output because the resolution failed - the stderr
// message is swallowed by `cli.stderrPrint` and doesn't land in
@ -2161,7 +2222,7 @@ test "run: as_of with matching snapshot produces body output" {
var buf: [32_768]u8 = undefined;
var stream = std.Io.Writer.fixed(&buf);
try runBands(io, testing.allocator, &svc, pf, .{ .events_enabled = false, .as_of = d, .from_snapshot = true, .today = d, .overlay_actuals = false }, false, &stream);
try runBands(io, testing.allocator, &svc, pf, .{ .events_enabled = false, .as_of = d, .from_snapshot = true, .today = d, .overlay_actuals = false }, false, &stream, null);
const out = stream.buffered();
// Header should call out the as-of date explicitly.
@ -2192,7 +2253,7 @@ test "run: as_of auto-snap surfaces muted 'nearest' note" {
var stream = std.Io.Writer.fixed(&buf);
const requested = Date.fromYmd(2026, 3, 13);
try runBands(io, testing.allocator, &svc, pf, .{ .events_enabled = false, .as_of = requested, .from_snapshot = true, .today = requested, .overlay_actuals = false }, false, &stream);
try runBands(io, testing.allocator, &svc, pf, .{ .events_enabled = false, .as_of = requested, .from_snapshot = true, .today = requested, .overlay_actuals = false }, false, &stream, null);
const out = stream.buffered();
try testing.expect(std.mem.indexOf(u8, out, "as of 2026-03-12") != null);