add theming (chart graphics only)

This commit is contained in:
Emil Lerch 2026-06-27 10:01:53 -07:00
parent 3fda9eabcf
commit ec96b60eb4
Signed by: lobo
GPG key ID: A7B62D657EF764F8
6 changed files with 102 additions and 43 deletions

View file

@ -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 <PATH>` (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),
);
}

View file

@ -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 <PATH>`
/// 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.

View file

@ -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 <PATH>`
/// 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 <WxH>`), 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

View file

@ -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) {

View file

@ -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);

View file

@ -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 <PATH> 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 <PATH>` (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 <PATH>` 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)