diff --git a/src/commands/projections.zig b/src/commands/projections.zig index fc5bd2d..f3ff38f 100644 --- a/src/commands/projections.zig +++ b/src/commands/projections.zig @@ -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);