diff --git a/src/chart_export.zig b/src/chart_export.zig index 59fce76..549bc55 100644 --- a/src/chart_export.zig +++ b/src/chart_export.zig @@ -17,10 +17,10 @@ //! The TUI's adaptive chart sizing isn't used here because the //! export target is a file, not a cell grid. //! -//! The module deliberately reuses the TUI's `default_theme` for -//! consistent visual identity across the live TUI surface and -//! exported images. A `--theme` override at export time is out of -//! scope for V1 (see TODO follow-up). +//! Each `export*` takes the `theme.Theme` to render with - the CLI +//! resolves it from `--theme ` (a `theme.srf`), defaulting to +//! the built-in theme so exports keep the TUI's visual identity unless +//! the user opts into a custom or presentation-friendly palette. const std = @import("std"); const z2d = @import("z2d"); @@ -50,6 +50,7 @@ pub fn exportSymbolChart( alloc: std.mem.Allocator, candles: []const zfin.Candle, display_count: usize, + th: theme.Theme, path: []const u8, ) !void { var cached = chart.computeIndicatorsWarmup(alloc, candles, display_count, 20) catch |err| switch (err) { @@ -67,7 +68,7 @@ pub fn exportSymbolChart( null, default_width, default_height, - theme.default_theme, + th, &cached, true, ) catch |err| switch (err) { @@ -90,6 +91,7 @@ pub fn exportProjectionChart( bands: []const projections.YearPercentiles, actuals: ?projection_chart.ActualsOverlay, retirement_boundary_year: ?u16, + th: theme.Theme, path: []const u8, ) !void { var rendered = projection_chart.renderToSurface( @@ -98,7 +100,7 @@ pub fn exportProjectionChart( bands, default_width, default_height, - theme.default_theme, + th, actuals, true, retirement_boundary_year, @@ -119,6 +121,7 @@ pub fn exportTimelineChart( alloc: std.mem.Allocator, points: []const line_chart.LinePoint, baseline: line_chart.Baseline, + th: theme.Theme, path: []const u8, ) !void { var rendered = line_chart.renderToSurface( @@ -127,7 +130,7 @@ pub fn exportTimelineChart( points, default_width, default_height, - theme.default_theme, + th, .{ .baseline = baseline, .axis_labels = true }, ) catch |err| switch (err) { error.InsufficientData => return error.InsufficientData, @@ -145,6 +148,7 @@ pub fn exportConvergenceChart( io: std.Io, alloc: std.mem.Allocator, points: []const forecast.ConvergencePoint, + th: theme.Theme, path: []const u8, ) !void { var rendered = forecast_chart.renderConvergenceToSurface( @@ -153,7 +157,7 @@ pub fn exportConvergenceChart( points, default_width, default_height, - theme.default_theme, + th, true, ) catch |err| switch (err) { error.InsufficientData => return error.InsufficientData, @@ -171,6 +175,7 @@ pub fn exportBacktestChart( io: std.Io, alloc: std.mem.Allocator, anchors: []const forecast.BacktestAnchor, + th: theme.Theme, path: []const u8, ) !void { var rendered = forecast_chart.renderBacktestToSurface( @@ -179,7 +184,7 @@ pub fn exportBacktestChart( anchors, default_width, default_height, - theme.default_theme, + th, true, ) catch |err| switch (err) { error.InsufficientData => return error.InsufficientData, @@ -198,6 +203,7 @@ pub fn exportCompareChart( alloc: std.mem.Allocator, then_bands: []const projections.YearPercentiles, now_bands: []const projections.YearPercentiles, + th: theme.Theme, path: []const u8, ) !void { var rendered = compare_chart.renderToSurface( @@ -207,7 +213,7 @@ pub fn exportCompareChart( now_bands, default_width, default_height, - theme.default_theme, + th, true, ) catch |err| switch (err) { error.InsufficientData => return error.InsufficientData, @@ -248,7 +254,7 @@ test "exportSymbolChart writes a non-empty PNG file" { const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_symbol.png" }); defer alloc.free(path); - try exportSymbolChart(io, alloc, &candles, 60, path); + try exportSymbolChart(io, alloc, &candles, 60, theme.default_theme, path); // Verify the file exists, starts with the PNG magic, and is // big enough to plausibly contain a chart (not just headers). @@ -291,7 +297,7 @@ test "exportSymbolChart returns InsufficientData on too-few candles" { try std.testing.expectError( error.InsufficientData, - exportSymbolChart(io, alloc, &candles, 60, path), + exportSymbolChart(io, alloc, &candles, 60, theme.default_theme, path), ); } @@ -322,7 +328,7 @@ test "exportProjectionChart writes a non-empty PNG file" { const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_projection.png" }); defer alloc.free(path); - try exportProjectionChart(io, alloc, &bands, null, null, path); + try exportProjectionChart(io, alloc, &bands, null, null, theme.default_theme, path); var file = try tmp.dir.openFile(io, "test_export_projection.png", .{}); defer file.close(io); @@ -358,7 +364,7 @@ test "exportProjectionChart returns InsufficientData with single band" { try std.testing.expectError( error.InsufficientData, - exportProjectionChart(io, alloc, &bands, null, null, path), + exportProjectionChart(io, alloc, &bands, null, null, theme.default_theme, path), ); } @@ -383,7 +389,7 @@ test "exportTimelineChart writes a non-empty PNG file" { const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_timeline.png" }); defer alloc.free(path); - try exportTimelineChart(io, alloc, &points, .fit, path); + try exportTimelineChart(io, alloc, &points, .fit, theme.default_theme, path); var file = try tmp.dir.openFile(io, "test_export_timeline.png", .{}); defer file.close(io); @@ -413,7 +419,7 @@ test "exportTimelineChart returns InsufficientData with a single point" { try std.testing.expectError( error.InsufficientData, - exportTimelineChart(io, alloc, &points, .fit, path), + exportTimelineChart(io, alloc, &points, .fit, theme.default_theme, path), ); } @@ -436,7 +442,7 @@ test "exportConvergenceChart writes a non-empty PNG file" { const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_convergence.png" }); defer alloc.free(path); - try exportConvergenceChart(io, alloc, &points, path); + try exportConvergenceChart(io, alloc, &points, theme.default_theme, path); var file = try tmp.dir.openFile(io, "test_export_convergence.png", .{}); defer file.close(io); @@ -468,7 +474,7 @@ test "exportConvergenceChart returns InsufficientData with a single point" { try std.testing.expectError( error.InsufficientData, - exportConvergenceChart(io, alloc, &points, path), + exportConvergenceChart(io, alloc, &points, theme.default_theme, path), ); } @@ -491,7 +497,7 @@ test "exportBacktestChart writes a non-empty PNG file" { const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_backtest.png" }); defer alloc.free(path); - try exportBacktestChart(io, alloc, &anchors, path); + try exportBacktestChart(io, alloc, &anchors, theme.default_theme, path); var file = try tmp.dir.openFile(io, "test_export_backtest.png", .{}); defer file.close(io); @@ -523,7 +529,7 @@ test "exportBacktestChart returns InsufficientData with a single anchor" { try std.testing.expectError( error.InsufficientData, - exportBacktestChart(io, alloc, &anchors, path), + exportBacktestChart(io, alloc, &anchors, theme.default_theme, path), ); } @@ -548,7 +554,7 @@ test "exportCompareChart writes a non-empty PNG file" { const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_compare.png" }); defer alloc.free(path); - try exportCompareChart(io, alloc, &then_bands, &now_bands, path); + try exportCompareChart(io, alloc, &then_bands, &now_bands, theme.default_theme, path); var file = try tmp.dir.openFile(io, "test_export_compare.png", .{}); defer file.close(io); @@ -579,6 +585,6 @@ test "exportCompareChart returns InsufficientData with a single-year side" { try std.testing.expectError( error.InsufficientData, - exportCompareChart(io, alloc, &then_bands, &now_bands, path), + exportCompareChart(io, alloc, &then_bands, &now_bands, theme.default_theme, path), ); } diff --git a/src/commands/framework.zig b/src/commands/framework.zig index cbb7485..dc35cf8 100644 --- a/src/commands/framework.zig +++ b/src/commands/framework.zig @@ -45,6 +45,7 @@ const zfin = @import("../root.zig"); const validator = @import("../comptime_validator.zig"); const chart = @import("../charts/chart.zig"); const term_query = @import("../term_query.zig"); +const theme = @import("../tui/theme.zig"); // ── Group taxonomy ──────────────────────────────────────────── @@ -259,6 +260,11 @@ pub const RunCtx = struct { /// once at invocation entry. Commands consult this together with /// `globals.chart_config` to choose kitty-graphics vs braille output. graphics_caps: term_query.Caps = .{}, + /// Theme for all CLI charts - inline terminal (kitty) charts and + /// `--export-chart` PNGs - resolved from the global `--theme ` + /// flag (default: the built-in theme). The TUI loads its own theme + /// from `theme.srf` independently. + chart_theme: theme.Theme = theme.default_theme, /// Resolve the portfolio pattern(s) (from `-p`/`--portfolio` or /// the default `portfolio*.srf` pattern) through cwd -> ZFIN_HOME. diff --git a/src/commands/history.zig b/src/commands/history.zig index 0b95e8b..3f2a7e2 100644 --- a/src/commands/history.zig +++ b/src/commands/history.zig @@ -138,6 +138,11 @@ pub const PortfolioOpts = struct { /// minimum if the series itself dips negative). Only consulted when /// `--export-chart` is given. baseline: line_chart.Baseline = .fit, + /// Theme for the chart - both the inline kitty image and the + /// `--export-chart` PNG. Comes from the global `--theme ` + /// flag; injected by `run` from `RunCtx.chart_theme`. Defaults to + /// the built-in theme. + chart_theme: theme.Theme = theme.default_theme, }; /// Parse the arg list for portfolio-mode flags. Pure function - no IO. @@ -224,9 +229,11 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { switch (parsed) { .symbol => |sym| try runSymbol(ctx.io, svc, sym, ctx.today, ctx.color, ctx.out, fetch_opts), .portfolio => |opts| { + var o = opts; + o.chart_theme = ctx.chart_theme; const pf = ctx.resolvePortfolioPath(); defer pf.deinit(ctx.allocator); - try runPortfolio(ctx.io, ctx.allocator, pf.path, opts, ctx.color, ctx.out, ctx.globals.chart_config, ctx.graphics_caps); + try runPortfolio(ctx.io, ctx.allocator, pf.path, o, ctx.color, ctx.out, ctx.globals.chart_config, ctx.graphics_caps); }, } } @@ -328,7 +335,7 @@ fn runPortfolio( // as a PNG (honoring --since / --until / --metric / --baseline) and // exit without printing the normal timeline output. if (opts.export_chart) |path| { - exportMetricChart(io, allocator, filtered, opts.metric, opts.baseline, path) catch |err| switch (err) { + exportMetricChart(io, allocator, filtered, opts.metric, opts.baseline, opts.chart_theme, path) catch |err| switch (err) { error.InsufficientData => { cli.stderrPrint(io, "Error: need at least 2 snapshots in the selected range to render a chart.\n"); return; @@ -360,7 +367,7 @@ fn runPortfolio( // 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 k: KittyChart = .{ .io = io, .caps = caps, .baseline = opts.baseline, .theme = opts.chart_theme }; const chart_render: ChartRender = switch (chart_config.mode) { .braille => .braille, .kitty => .{ .kitty = k }, @@ -549,13 +556,14 @@ fn exportMetricChart( points: []const timeline.TimelinePoint, metric: timeline.Metric, baseline: line_chart.Baseline, + th: theme.Theme, path: []const u8, ) !void { const series = try timeline.extractChartSeries(allocator, points, metric); defer allocator.free(series); const lps = try metricLinePoints(allocator, series); defer allocator.free(lps); - try chart_export.exportTimelineChart(io, allocator, lps, baseline, path); + try chart_export.exportTimelineChart(io, allocator, lps, baseline, th, path); } /// How `renderPortfolio` draws the timeline chart. @@ -571,6 +579,7 @@ const KittyChart = struct { io: std.Io, caps: term_query.Caps, baseline: line_chart.Baseline, + theme: theme.Theme, }; /// Draw the focused-metric timeline. Dispatches to inline kitty graphics @@ -614,7 +623,7 @@ fn emitTimelineKitty( 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 }); + var rendered = try line_chart.renderToSurface(k.io, allocator, lps, dims.width, dims.height, k.theme, .{ .baseline = k.baseline, .axis_labels = true }); defer rendered.deinit(allocator); const rgb = try rendered.extractRgb(allocator); defer allocator.free(rgb); @@ -1167,7 +1176,7 @@ test "exportMetricChart writes a PNG for a multi-point timeline" { const path = try std.fs.path.join(alloc, &.{ path_buf[0..dir_len], "history_timeline.png" }); defer alloc.free(path); - try exportMetricChart(io, alloc, &pts, .liquid, .fit, path); + try exportMetricChart(io, alloc, &pts, .liquid, .fit, theme.default_theme, path); var file = try tmp.dir.openFile(io, "history_timeline.png", .{}); defer file.close(io); @@ -1188,7 +1197,7 @@ test "exportMetricChart returns InsufficientData with fewer than 2 points" { const pts = [_]timeline.TimelinePoint{ makeTimelinePoint(2026, 1, 1, 1_000_000, 200_000, 1_200_000), }; - try testing.expectError(error.InsufficientData, exportMetricChart(io, alloc, &pts, .liquid, .fit, "unused.png")); + try testing.expectError(error.InsufficientData, exportMetricChart(io, alloc, &pts, .liquid, .fit, theme.default_theme, "unused.png")); } // ── rebuildRollup ──────────────────────────────────────────── diff --git a/src/commands/projections.zig b/src/commands/projections.zig index f818b38..c4c5930 100644 --- a/src/commands/projections.zig +++ b/src/commands/projections.zig @@ -260,8 +260,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { }; switch (parsed) { - .convergence => |args| try runConvergence(io, allocator, file_path, args.export_chart, color, out, kitty_caps), - .return_backtest => |args| try runReturnBacktest(io, allocator, file_path, args.real, args.export_chart, color, out, kitty_caps), + .convergence => |args| try runConvergence(io, allocator, file_path, args.export_chart, color, out, kitty_caps, ctx.chart_theme), + .return_backtest => |args| try runReturnBacktest(io, allocator, file_path, args.real, args.export_chart, color, out, kitty_caps, ctx.chart_theme), .compare => |args| { _ = ctx.svc orelse return error.MissingDataService; // Pre-load today's live composition only when it's @@ -318,6 +318,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { .overlay_actuals = args.overlay_actuals, .export_chart = args.export_chart, .live = if (live) |*l| l else null, + .chart_theme = ctx.chart_theme, }, color, out, @@ -465,6 +466,10 @@ pub const BandsOptions = struct { /// Snapshot-only as-of paths ignore this field. See /// `LiveData` for the rationale. live: ?*const LiveData = null, + /// Theme for the `--export-chart` PNG (resolved from `--theme`). + /// Defaults to the built-in theme; the inline kitty chart always + /// uses the default. + chart_theme: theme.Theme = theme.default_theme, }; /// Build a `ProjectionContext` for an already-resolved as-of date, @@ -622,6 +627,7 @@ fn emitBandsKitty( va: std.mem.Allocator, ctx: *const view.ProjectionContext, caps: term_query.Caps, + th: theme.Theme, out: *std.Io.Writer, ) !void { const horizons = ctx.config.getHorizons(); @@ -633,7 +639,7 @@ fn emitBandsKitty( const oc = prepOverlayChart(va, ctx, bands_ec); const d = projectionChartDims(caps); - var rendered = try projection_chart.renderToSurface(io, va, oc.bands, d.width, d.height, theme.default_theme, oc.overlay, true, ctx.retirement.boundaryYear()); + var rendered = try projection_chart.renderToSurface(io, va, oc.bands, d.width, d.height, th, oc.overlay, true, ctx.retirement.boundaryYear()); defer rendered.deinit(va); const rgb = try rendered.extractRgb(va); try term_graphics.placeInline(out, va, rgb, d.width, d.height, d.cols, d.rows); @@ -765,7 +771,7 @@ pub fn runBands( // overlay window) the same way the inline-kitty path does. const oc = prepOverlayChart(va, &ctx, bands_ec); - chart_export.exportProjectionChart(io, allocator, oc.bands, oc.overlay, ctx.retirement.boundaryYear(), export_path) catch |err| switch (err) { + chart_export.exportProjectionChart(io, allocator, oc.bands, oc.overlay, ctx.retirement.boundaryYear(), opts.chart_theme, export_path) catch |err| switch (err) { error.InsufficientData => { cli.stderrPrint(io, "Error: not enough projection data to render a chart.\n"); return; @@ -799,7 +805,7 @@ pub fn runBands( // keep the table-only view below. if (kitty_caps) |kc| { try out.print("\n", .{}); - emitBandsKitty(io, va, &ctx, kc, out) catch |err| switch (err) { + emitBandsKitty(io, va, &ctx, kc, opts.chart_theme, out) catch |err| switch (err) { error.InsufficientData => {}, // no bands yet; fall through to the table else => return err, }; @@ -1155,7 +1161,7 @@ pub fn runCompare( cli.stderrPrint(io, "Error: projection bands unavailable for one side; cannot export comparison chart.\n"); return; } - chart_export.exportCompareChart(io, allocator, result.then_bands.?, result.now_bands.?, export_path) catch |err| switch (err) { + chart_export.exportCompareChart(io, allocator, result.then_bands.?, result.now_bands.?, ctx.chart_theme, export_path) catch |err| switch (err) { error.InsufficientData => { cli.stderrPrint(io, "Error: not enough projection data to render a comparison chart.\n"); return; @@ -1213,7 +1219,7 @@ pub fn runCompare( if (kitty_caps) |kc| { if (result.then_bands != null and result.now_bands != null) { const d = projectionChartDims(kc); - if (compare_chart.renderCompareChart(io, va, result.then_bands.?, result.now_bands.?, d.width, d.height, theme.default_theme)) |cres| { + if (compare_chart.renderCompareChart(io, va, result.then_bands.?, result.now_bands.?, d.width, d.height, ctx.chart_theme)) |cres| { try term_graphics.placeInline(out, va, cres.rgb_data, d.width, d.height, d.cols, d.rows); try out.print("\n", .{}); } else |err| switch (err) { @@ -1261,6 +1267,7 @@ pub fn runConvergence( color: bool, out: *std.Io.Writer, kitty_caps: ?term_query.Caps, + chart_theme: theme.Theme, ) !void { var arena_state = std.heap.ArenaAllocator.init(allocator); defer arena_state.deinit(); @@ -1280,7 +1287,7 @@ pub fn runConvergence( // --export-chart: render the convergence chart to a PNG and exit // before any text output. if (export_chart) |export_path| { - chart_export.exportConvergenceChart(io, allocator, points, export_path) catch |err| switch (err) { + chart_export.exportConvergenceChart(io, allocator, points, chart_theme, export_path) catch |err| switch (err) { error.InsufficientData => { cli.stderrPrint(io, "Error: not enough convergence data to render a chart.\n"); return; @@ -1298,7 +1305,7 @@ pub fn runConvergence( // skips the chart and still renders the table below. if (kitty_caps) |kc| { const d = projectionChartDims(kc); - if (forecast_chart.renderConvergenceChart(io, va, points, d.width, d.height, theme.default_theme)) |result| { + if (forecast_chart.renderConvergenceChart(io, va, points, d.width, d.height, chart_theme)) |result| { try out.print("\n", .{}); try term_graphics.placeInline(out, va, result.rgb_data, d.width, d.height, d.cols, d.rows); } else |err| switch (err) { @@ -1332,6 +1339,7 @@ pub fn runReturnBacktest( color: bool, out: *std.Io.Writer, kitty_caps: ?term_query.Caps, + chart_theme: theme.Theme, ) !void { var arena_state = std.heap.ArenaAllocator.init(allocator); defer arena_state.deinit(); @@ -1359,7 +1367,7 @@ pub fn runReturnBacktest( // --export-chart: render the back-test chart to a PNG and exit. if (export_chart) |export_path| { - chart_export.exportBacktestChart(io, allocator, anchors, export_path) catch |err| switch (err) { + chart_export.exportBacktestChart(io, allocator, anchors, chart_theme, export_path) catch |err| switch (err) { error.InsufficientData => { cli.stderrPrint(io, "Error: not enough back-test data to render a chart.\n"); return; @@ -1376,7 +1384,7 @@ pub fn runReturnBacktest( // runConvergence). Too few anchors skips the chart; table still renders. if (kitty_caps) |kc| { const d = projectionChartDims(kc); - if (forecast_chart.renderBacktestChart(io, va, anchors, d.width, d.height, theme.default_theme)) |result| { + if (forecast_chart.renderBacktestChart(io, va, anchors, d.width, d.height, chart_theme)) |result| { try out.print("\n", .{}); try term_graphics.placeInline(out, va, result.rgb_data, d.width, d.height, d.cols, d.rows); } else |err| switch (err) { diff --git a/src/commands/quote.zig b/src/commands/quote.zig index bc4ec03..2e20feb 100644 --- a/src/commands/quote.zig +++ b/src/commands/quote.zig @@ -145,7 +145,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { // PNG export short-circuits all text rendering. if (parsed.export_chart) |path| { - chart_export.exportSymbolChart(ctx.io, ctx.allocator, candles, display_count, path) catch |err| switch (err) { + chart_export.exportSymbolChart(ctx.io, ctx.allocator, candles, display_count, ctx.chart_theme, path) catch |err| switch (err) { error.InsufficientData => { cli.stderrPrint(ctx.io, "Error: not enough candle history to render a chart (need >= 20 candles).\n"); return; @@ -214,7 +214,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { } } - const k: KittyChart = .{ .io = ctx.io, .caps = ctx.graphics_caps }; + const k: KittyChart = .{ .io = ctx.io, .caps = ctx.graphics_caps, .theme = ctx.chart_theme }; const chart_render: ChartRender = switch (ctx.globals.chart_config.mode) { .braille => .braille, .kitty => .{ .kitty = k }, @@ -282,6 +282,7 @@ const ChartRender = union(enum) { const KittyChart = struct { io: std.Io, caps: term_query.Caps, + theme: theme.Theme, }; /// Braille price chart of the most recent `display_count` candles (the @@ -309,7 +310,7 @@ fn emitQuoteKitty(allocator: std.mem.Allocator, out: *std.Io.Writer, candles: [] const cols = term_graphics.quote_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 tui_chart.renderToSurface(k.io, allocator, display_data, null, dims.width, dims.height, theme.default_theme, &cached, true); + var rendered = try tui_chart.renderToSurface(k.io, allocator, display_data, null, dims.width, dims.height, k.theme, &cached, true); defer rendered.deinit(allocator); const rgb = try rendered.extractRgb(allocator); defer allocator.free(rgb); diff --git a/src/main.zig b/src/main.zig index d9c1592..e08a296 100644 --- a/src/main.zig +++ b/src/main.zig @@ -4,6 +4,7 @@ const tui = @import("tui.zig"); const cli = @import("commands/common.zig"); const cmd_framework = @import("commands/framework.zig"); const chart = @import("charts/chart.zig"); +const theme = @import("tui/theme.zig"); const term_query = @import("term_query.zig"); /// Comptime registry of CLI commands. Field name is the user-facing @@ -134,6 +135,11 @@ const interactive_help = \\ (e.g. 80x24); `auto` picks Kitty graphics \\ if the terminal supports it, otherwise \\ braille + \\ --theme Theme file (a `theme.srf`) applied to all + \\ charts - inline terminal charts and + \\ `--export-chart` PNGs. Default: built-in + \\ theme. Generate one with + \\ `zfin interactive --default-theme`. \\ --default-keys Print default keybindings as a `keys.srf` \\ template and exit (no TUI launched). \\ Pipe to `~/.config/zfin/keys.srf` to @@ -166,6 +172,10 @@ const Globals = struct { refresh_policy: cmd_framework.RefreshPolicy = .auto, /// Chart graphics mode from `--chart` (auto / braille / WxH). chart_config: chart.ChartConfig = .{}, + /// Theme file from `--theme ` (a `theme.srf`), applied to all + /// charts (inline terminal charts and `--export-chart` PNGs). Null = + /// the built-in default theme. + theme_path: ?[]const u8 = null, /// Index into args of the first post-global token (the subcommand). cursor: usize, }; @@ -299,6 +309,12 @@ fn parseGlobals(allocator: std.mem.Allocator, args: []const []const u8) GlobalPa i += 2; continue; } + if (std.mem.eql(u8, a, "--theme")) { + if (i + 1 >= args.len) return error.MissingValue; + g.theme_path = args[i + 1]; + i += 2; + continue; + } // Help flags are subcommand-like tokens, stop scanning. if (std.mem.eql(u8, a, "--help") or std.mem.eql(u8, a, "-h")) break; @@ -309,6 +325,18 @@ fn parseGlobals(allocator: std.mem.Allocator, args: []const []const u8) GlobalPa return g; } +/// Resolve the `--theme ` flag into a `theme.Theme` for all chart +/// rendering (inline terminal charts and `--export-chart` PNGs). A null +/// path uses the built-in default; a non-null path that fails to load +/// warns and falls back to the default rather than aborting the command. +fn resolveChartTheme(io: std.Io, allocator: std.mem.Allocator, path: ?[]const u8) theme.Theme { + const p = path orelse return theme.default_theme; + return theme.loadFromFile(io, allocator, p) orelse blk: { + cli.stderrPrint(io, "Note: could not load --theme file; using the default theme.\n"); + break :blk theme.default_theme; + }; +} + pub fn main(init: std.process.Init) !u8 { return runCli(init) catch |err| switch (err) { // Downstream pipe closed (e.g., `zfin earnings AAPL | head`). Zig's @@ -522,6 +550,7 @@ fn runCli(init: std.process.Init) !u8 { .color = color, .out = out, .graphics_caps = term_query.detect(io, init.environ_map), + .chart_theme = resolveChartTheme(io, allocator, globals.theme_path), }; const dispatched_args = if (comptime Module.meta.uppercase_first_arg) try cmd_framework.normalizeFirstArg(allocator, cmd_args)