add theming (chart graphics only)
This commit is contained in:
parent
3fda9eabcf
commit
ec96b60eb4
6 changed files with 102 additions and 43 deletions
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
29
src/main.zig
29
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 <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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue