additional spacing based on crypto example

This commit is contained in:
Emil Lerch 2026-06-30 11:42:49 -07:00
parent baf5e2a86f
commit 3a1500bfbb
Signed by: lobo
GPG key ID: A7B62D657EF764F8
4 changed files with 226 additions and 54 deletions

View file

@ -5,6 +5,8 @@ const framework = @import("framework.zig");
const fmt = cli.fmt;
const Money = @import("../Money.zig");
const views = @import("../views/portfolio_sections.zig");
/// Main holdings-table column layout (widths + format strings).
const pl = views.PositionsLayout;
/// Visibility of expired options / matured CDs within the Options
/// and CDs sections.
@ -308,12 +310,8 @@ pub fn display(
// Column headers
try out.print("\n", .{});
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" " ++ fmt.sym_col_spec ++ " {s:>8} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13} {s}\n", .{
"Symbol", "Shares", "Avg Cost", "Price", "Market Value", "Gain/Loss", "Weight", "Date", "Account",
});
try out.print(" " ++ std.fmt.comptimePrint("{{s:->{d}}}", .{fmt.sym_col_width}) ++ " {s:->8} {s:->10} {s:->10} {s:->16} {s:->14} {s:->8} {s:->13} {s:->8}\n", .{
"", "", "", "", "", "", "", "", "",
});
try out.print(pl.header, pl.header_labels);
try out.print(pl.separator, pl.separator_fills);
try cli.reset(out, color);
// Position rows with lot detail
@ -345,19 +343,19 @@ pub fn display(
}
if (a.is_manual_price) try cli.setFg(out, color, cli.CLR_WARNING);
try out.print(" " ++ fmt.sym_col_spec ++ " {d:>8.1} {f} ", .{
a.display_symbol, a.shares, Money.from(a.avg_cost).padRight(10),
try out.print(" " ++ pl.symbol_spec ++ " " ++ pl.shares_num ++ " {f} ", .{
a.display_symbol, a.shares, Money.from(a.avg_cost).padRight(pl.price_w),
});
try out.print("{f}", .{Money.from(a.current_price).padRight(10)});
try out.print(" {f} ", .{Money.from(a.market_value).padRight(16)});
try out.print("{f}", .{Money.from(a.current_price).padRight(pl.price_w)});
try out.print(" {f} ", .{Money.from(a.market_value).padRight(pl.value_w)});
try cli.setGainLoss(out, color, a.unrealized_gain_loss);
try out.print("{s}{f}", .{ sign, Money.from(gl_abs).padRight(13) });
try out.print("{s}{f}", .{ sign, Money.from(gl_abs).padRight(pl.gainloss_w - 1) });
if (a.is_manual_price) {
try cli.setFg(out, color, cli.CLR_WARNING);
} else {
try cli.reset(out, color);
}
try out.print(" {d:>7.1}%", .{a.weight * 100.0});
try out.print(" " ++ pl.weight_num, .{a.weight * 100.0});
if (date_col_len > 0) {
try out.print(" {s}", .{date_col[0..date_col_len]});
}
@ -411,19 +409,17 @@ pub fn display(
}
// Totals line
try cli.printFg(out, color, cli.CLR_MUTED, " {s:->6} {s:->8} {s:->10} {s:->10} {s:->16} {s:->14} {s:->8}\n", .{
"", "", "", "", "", "", "",
});
try cli.printFg(out, color, cli.CLR_MUTED, pl.total_sep, pl.total_sep_fills);
{
const gl_abs = if (summary.unrealized_gain_loss >= 0) summary.unrealized_gain_loss else -summary.unrealized_gain_loss;
try out.print(" {s:>6} {s:>8} {s:>10} {s:>10} {f} ", .{
"", "", "", "TOTAL", Money.from(summary.total_value).padRight(16),
try out.print(pl.total, .{
"", "", "", "TOTAL", Money.from(summary.total_value).padRight(pl.value_w),
});
try cli.printGainLoss(out, color, summary.unrealized_gain_loss, "{c}{f}", .{
@as(u8, if (summary.unrealized_gain_loss >= 0) '+' else '-'),
Money.from(gl_abs).padRight(13),
Money.from(gl_abs).padRight(pl.gainloss_w - 1),
});
try out.print(" {s:>7}\n", .{"100.0%"});
try out.print(" " ++ pl.weight_str ++ "\n", .{"100.0%"});
}
if (summary.realized_gain_loss != 0) {
@ -593,7 +589,7 @@ pub fn display(
std.fmt.bufPrint(&price_str, "{f}", .{Money.from(close)}) catch "$?"
else
"--";
try out.print(" " ++ fmt.sym_col_spec ++ " {s:>10}\n", .{ sym, ps });
try out.print(" " ++ pl.symbol_spec ++ " " ++ pl.price_str ++ "\n", .{ sym, ps });
}
}
@ -616,11 +612,11 @@ pub fn printLotRow(as_of: zfin.Date, out: *std.Io.Writer, color: bool, lot: zfin
const lot_gl_abs = if (gl >= 0) gl else -gl;
const lot_sign: []const u8 = if (gl >= 0) "+" else "-";
try cli.printFg(out, color, cli.CLR_MUTED, " " ++ fmt.sym_col_spec ++ " {d:>8.1} {f} {s:>10} {f} ", .{
status_str, lot.shares, Money.from(lot.open_price).padRight(10), "", Money.from(lot.shares * use_price).padRight(16),
try cli.printFg(out, color, cli.CLR_MUTED, " " ++ pl.symbol_spec ++ " " ++ pl.shares_num ++ " {f} " ++ pl.price_str ++ " {f} ", .{
status_str, lot.shares, Money.from(lot.open_price).padRight(pl.price_w), "", Money.from(lot.shares * use_price).padRight(pl.value_w),
});
try cli.printGainLoss(out, color, gl, "{s}{f}", .{ lot_sign, Money.from(lot_gl_abs).padRight(13) });
try cli.printFg(out, color, cli.CLR_MUTED, " {s:>8} {f} {s} {s}\n", .{ "", lot.open_date, indicator, acct_col });
try cli.printGainLoss(out, color, gl, "{s}{f}", .{ lot_sign, Money.from(lot_gl_abs).padRight(pl.gainloss_w - 1) });
try cli.printFg(out, color, cli.CLR_MUTED, " " ++ pl.weight_str ++ " {f} {s} {s}\n", .{ "", lot.open_date, indicator, acct_col });
}
// Tests
@ -718,6 +714,98 @@ test "display shows header and summary" {
try testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
}
test "display widens columns for large crypto values" {
// Regression guard for the portfolio-table column widening. A long
// crypto symbol (DOGE-USD, 8 chars), a large share count, and a high
// price must render in full without overflowing or shifting the
// symbol / shares / price / value columns.
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
var lots = [_]zfin.Lot{
.{ .symbol = "DOGE-USD", .shares = 10000, .open_date = zfin.Date.fromYmd(2024, 1, 2), .open_price = 41500.0, .account = "Sample Account" },
};
var portfolio = testPortfolio(&lots);
var positions = [_]zfin.Position{
.{ .symbol = "DOGE-USD", .shares = 10000, .avg_cost = 41500.0, .total_cost = 415000000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
};
var allocs = [_]zfin.valuation.Allocation{
.{ .symbol = "DOGE-USD", .display_symbol = "DOGE-USD", .shares = 10000, .avg_cost = 41500.0, .current_price = 42000.0, .market_value = 420000000.0, .cost_basis = 415000000.0, .weight = 1.0, .unrealized_gain_loss = 5000000.0, .unrealized_return = 0.01205 },
};
const summary = testSummary(&allocs);
var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
defer candle_map.deinit();
const pf_data = testPortfolioData(summary, candle_map);
var watch_prices = std.StringHashMap(f64).init(testing.allocator);
defer watch_prices.deinit();
const watch_syms: []const []const u8 = &.{};
try display(testing.allocator, &w, false, "crypto.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices, zfin.Date.fromYmd(2026, 5, 8), .rollup);
const out = w.buffered();
// Full 8-char symbol present (not truncated).
try testing.expect(std.mem.indexOf(u8, out, "DOGE-USD") != null);
// Symbol column is wide enough that DOGE-USD does not butt up against
// the next column: it is followed by >= 2 spaces (in-column padding
// plus the separator). At the old 7-wide column there would be only
// the single separator space, so this catches a narrow-column regression.
try testing.expect(std.mem.indexOf(u8, out, "DOGE-USD ") != null);
// Large share count, high price, and large market value render in full.
try testing.expect(std.mem.indexOf(u8, out, "10000.0") != null);
try testing.expect(std.mem.indexOf(u8, out, "$42,000.00") != null);
try testing.expect(std.mem.indexOf(u8, out, "$420,000,000.00") != null);
// Gain/Loss of $5M fits its column without truncation.
try testing.expect(std.mem.indexOf(u8, out, "$5,000,000.00") != null);
// No ANSI codes when color is disabled.
try testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
// The single position's market value equals the portfolio total, so
// it must appear at the same column in the data row and the TOTAL
// row. This guards the TOTAL line against drifting out of alignment
// when the data columns are widened.
var data_col: ?usize = null;
var total_col: ?usize = null;
var lit = std.mem.splitScalar(u8, out, '\n');
while (lit.next()) |line| {
const at = std.mem.indexOf(u8, line, "$420,000,000.00") orelse continue;
if (std.mem.indexOf(u8, line, "DOGE-USD") != null) {
data_col = at;
} else if (std.mem.indexOf(u8, line, "TOTAL") != null) {
total_col = at;
}
}
try testing.expect(data_col != null);
try testing.expect(total_col != null);
try testing.expectEqual(data_col.?, total_col.?);
// The Date column is left-justified text (like the symbol column), so
// the "Date" header sits over the START of the date values, and the
// Account column lines up too. Verify the header label and the data
// value share a column.
var hdr_date: ?usize = null;
var row_date: ?usize = null;
var hdr_acct: ?usize = null;
var row_acct: ?usize = null;
var dit = std.mem.splitScalar(u8, out, '\n');
while (dit.next()) |line| {
if (std.mem.indexOf(u8, line, "Symbol") != null) {
hdr_date = std.mem.indexOf(u8, line, "Date");
hdr_acct = std.mem.indexOf(u8, line, "Account");
} else if (std.mem.indexOf(u8, line, "DOGE-USD") != null) {
row_date = std.mem.indexOf(u8, line, "2024-01-02");
row_acct = std.mem.indexOf(u8, line, "Sample Account");
}
}
try testing.expect(hdr_date != null and row_date != null);
try testing.expectEqual(hdr_date.?, row_date.?);
try testing.expect(hdr_acct != null and row_acct != null);
try testing.expectEqual(hdr_acct.?, row_acct.?);
}
test "display with watchlist" {
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);

View file

@ -16,11 +16,6 @@ const PerformanceResult = @import("analytics/performance.zig").PerformanceResult
// Layout constants
/// Width of the symbol column in portfolio view (CLI + TUI).
pub const sym_col_width = 7;
/// Comptime format spec for left-aligned symbol column, e.g. "{s:<7}".
pub const sym_col_spec = std.fmt.comptimePrint("{{s:<{d}}}", .{sym_col_width});
/// Width of the account name column in cash section (CLI + TUI).
pub const cash_acct_width = 30;

View file

@ -4,6 +4,8 @@ const zfin = @import("../root.zig");
const fmt = @import("../format.zig");
const Money = @import("../Money.zig");
const views = @import("../views/portfolio_sections.zig");
/// Main holdings-table column layout (widths + per-column specs).
const pl = views.PositionsLayout;
const theme = @import("theme.zig");
const tui = @import("../tui.zig");
const framework = @import("tab_framework.zig");
@ -13,10 +15,14 @@ const StyledLine = tui.StyledLine;
const colLabel = tui.colLabel;
// Portfolio column layout (display columns).
// Each column width includes its trailing separator space.
// prefix(4) + sym(sw+1) + shares(8+1) + avgcost(10+1) + price(10+1) + mv(16+1) + gl(14+1) + weight(8) + date(13+1) + account
// Row shape: prefix(4) + sym + shares + avgcost + price + mv + gl + weight + date + account.
// Every non-prefix column width is owned by views/portfolio_sections.zig
// (PositionsLayout: symbol_w, shares_w, price_w, value_w, gainloss_w,
// weight_w, date_w); each column adds a 1-col separator space. The
// row/header format strings and the col_end_* hit-test offsets below all
// derive from those same widths, so they can't drift apart.
const prefix_cols: usize = 4;
const sw: usize = fmt.sym_col_width;
const sw: usize = pl.symbol_w;
// Portfolio-specific types
@ -775,14 +781,17 @@ fn toggleExpandAtCursor(state: *State, app: *App) void {
}
/// Cumulative column end positions for click-to-sort hit testing.
pub const col_end_symbol: usize = prefix_cols + sw + 1;
pub const col_end_shares: usize = col_end_symbol + 9;
pub const col_end_avg_cost: usize = col_end_shares + 11;
pub const col_end_price: usize = col_end_avg_cost + 11;
pub const col_end_market_value: usize = col_end_price + 17;
pub const col_end_gain_loss: usize = col_end_market_value + 15;
pub const col_end_weight: usize = col_end_gain_loss + 9;
pub const col_end_date: usize = col_end_weight + 14;
/// Each offset is the previous column's end plus this column's content
/// width (from format.zig) plus the 1-col separator space, so the
/// hit-test grid stays locked to the row/header format strings.
const col_end_symbol: usize = prefix_cols + sw + 1;
const col_end_shares: usize = col_end_symbol + pl.shares_w + 1;
const col_end_avg_cost: usize = col_end_shares + pl.price_w + 1;
const col_end_price: usize = col_end_avg_cost + pl.price_w + 1;
const col_end_market_value: usize = col_end_price + pl.value_w + 1;
const col_end_gain_loss: usize = col_end_market_value + pl.gainloss_w + 1;
const col_end_weight: usize = col_end_gain_loss + pl.weight_w + 1;
const col_end_date: usize = col_end_weight + pl.date_w + 1;
// Gain/loss column start position (used for alt-style coloring)
const gl_col_start: usize = col_end_market_value;
@ -1661,16 +1670,16 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va
var mv_hdr_buf: [24]u8 = undefined;
var gl_hdr_buf: [24]u8 = undefined;
var wt_hdr_buf: [16]u8 = undefined;
const sym_hdr = colLabel(&sym_hdr_buf, "Symbol", fmt.sym_col_width, true, if (sf == .symbol) si else null);
const shr_hdr = colLabel(&shr_hdr_buf, "Shares", 8, false, if (sf == .shares) si else null);
const avg_hdr = colLabel(&avg_hdr_buf, "Avg Cost", 10, false, if (sf == .avg_cost) si else null);
const prc_hdr = colLabel(&prc_hdr_buf, "Price", 10, false, if (sf == .price) si else null);
const mv_hdr = colLabel(&mv_hdr_buf, "Market Value", 16, false, if (sf == .market_value) si else null);
const gl_hdr = colLabel(&gl_hdr_buf, "Gain/Loss", 14, false, if (sf == .gain_loss) si else null);
const wt_hdr = colLabel(&wt_hdr_buf, "Weight", 8, false, if (sf == .weight) si else null);
const sym_hdr = colLabel(&sym_hdr_buf, "Symbol", pl.symbol_w, true, if (sf == .symbol) si else null);
const shr_hdr = colLabel(&shr_hdr_buf, "Shares", pl.shares_w, false, if (sf == .shares) si else null);
const avg_hdr = colLabel(&avg_hdr_buf, "Avg Cost", pl.price_w, false, if (sf == .avg_cost) si else null);
const prc_hdr = colLabel(&prc_hdr_buf, "Price", pl.price_w, false, if (sf == .price) si else null);
const mv_hdr = colLabel(&mv_hdr_buf, "Market Value", pl.value_w, false, if (sf == .market_value) si else null);
const gl_hdr = colLabel(&gl_hdr_buf, "Gain/Loss", pl.gainloss_w, false, if (sf == .gain_loss) si else null);
const wt_hdr = colLabel(&wt_hdr_buf, "Weight", pl.weight_w, false, if (sf == .weight) si else null);
const acct_ind: []const u8 = if (sf == .account) si else "";
const hdr = try std.fmt.allocPrint(arena, " {s} {s} {s} {s} {s} {s} {s} {s:>13} {s}{s}", .{
const hdr = try std.fmt.allocPrint(arena, " {s} {s} {s} {s} {s} {s} {s} " ++ pl.date_str ++ " {s}{s}", .{
sym_hdr, shr_hdr, avg_hdr, prc_hdr, mv_hdr, gl_hdr, wt_hdr, "Date", acct_ind, "Account",
});
try lines.append(arena, .{ .text = hdr, .style = th.headerStyle() });
@ -1751,7 +1760,7 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va
else
a.weight;
const text = try std.fmt.allocPrint(arena, "{s}{s}" ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} {s:>10} {s:>16} {s:>14} {d:>7.1}% {s:>13} {s}", .{
const text = try std.fmt.allocPrint(arena, "{s}{s}" ++ pl.symbol_spec ++ " " ++ pl.shares_num ++ " " ++ pl.price_str ++ " " ++ pl.price_str ++ " " ++ pl.value_str ++ " " ++ pl.gainloss_str ++ " " ++ pl.weight_num ++ " " ++ pl.date_str ++ " {s}", .{
arrow, star, a.display_symbol, display_shares, cost_str, price_str, mv_str, pnl_str, display_weight * 100.0, date_col, acct_col,
});
@ -1765,7 +1774,7 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va
.style = base_style,
.alt_style = gl_style,
.alt_start = gl_col_start,
.alt_end = gl_col_start + 14,
.alt_end = gl_col_start + pl.gainloss_w,
});
}
}
@ -1799,7 +1808,7 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va
const indicator = fmt.capitalGainsIndicator(app.today, lot.open_date);
const lot_date_col = try std.fmt.allocPrint(arena, "{s} {s}", .{ date_str, indicator });
const acct_col: []const u8 = lot.account orelse "";
const text = try std.fmt.allocPrint(arena, " " ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13} {s}", .{
const text = try std.fmt.allocPrint(arena, " " ++ pl.symbol_spec ++ " " ++ pl.shares_num ++ " " ++ pl.price_str ++ " " ++ pl.price_str ++ " " ++ pl.value_str ++ " " ++ pl.gainloss_str ++ " " ++ pl.weight_str ++ " " ++ pl.date_str ++ " {s}", .{
status_str, lot.shares, lot_price_str, "", lot_mv_str, lot_gl_str, "", lot_date_col, acct_col,
});
const base_style = if (is_cursor) th.selectStyle() else th.mutedStyle();
@ -1809,7 +1818,7 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va
.style = base_style,
.alt_style = gl_col_style,
.alt_start = gl_col_start,
.alt_end = gl_col_start + 14,
.alt_end = gl_col_start + pl.gainloss_w,
});
}
},
@ -1820,7 +1829,7 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va
else
"--";
const star2: []const u8 = if (is_active_sym) "* " else " ";
const text = try std.fmt.allocPrint(arena, " {s}" ++ fmt.sym_col_spec ++ " {s:>8} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13}", .{
const text = try std.fmt.allocPrint(arena, " {s}" ++ pl.symbol_spec ++ " " ++ pl.shares_str ++ " " ++ pl.price_str ++ " " ++ pl.price_str ++ " " ++ pl.value_str ++ " " ++ pl.gainloss_str ++ " " ++ pl.weight_str ++ " " ++ pl.date_str, .{
star2, row.symbol, "--", "--", ps, "--", "--", "watch", "",
});
const row_style = if (is_cursor) th.selectStyle() else th.contentStyle();

View file

@ -1,4 +1,4 @@
//! View models for portfolio sections (Options, CDs).
//! View models for portfolio sections (Positions, Options, CDs).
//! Produces renderer-agnostic display data consumed by both CLI and TUI.
//! Column widths, format strings, computed values, and style decisions
//! are defined here. Renderers are thin adapters that map StyleIntent
@ -10,6 +10,86 @@ const Date = @import("../Date.zig");
const fmt = @import("../format.zig");
const Money = @import("../Money.zig");
// Positions (main holdings table)
/// Column layout for the main holdings table - the portfolio's primary
/// table, shared by the CLI (commands/portfolio.zig) and the TUI
/// (tui/portfolio_tab.zig). This is the single source of truth for the
/// column widths, so the CLI header/separator/rows, the TUI header/rows,
/// and the TUI click-to-sort hit-test offsets (portfolio_tab.col_end_*)
/// all derive from the same numbers and can't drift apart.
///
/// Unlike the Options / CDs layouts, the rows don't collapse into one
/// `data_row` string: the gain/loss cell is colored (so the CLI splits
/// the row across several prints) and the shares / weight cells render a
/// float inline in data rows but a pre-formatted string in headers and
/// the TUI's pre-rendered cells. So the per-column specs are exposed
/// individually alongside the composed header / separator / total
/// strings.
pub const PositionsLayout = struct {
const cp = std.fmt.comptimePrint;
// Column content widths. The 1-col separating space between columns
// is added at layout time, not included here.
pub const symbol_w = 10;
pub const shares_w = 12;
pub const price_w = 12; // avg cost and current price
pub const value_w = 16; // market value
pub const gainloss_w = 14; // sign + amount
pub const weight_w = 8; // "NNN.N%"
pub const date_w = 13;
pub const account_w = 8; // separator rule only; header/data are natural width
// Per-column format specs. `symbol_spec` and `date_str` left-justify
// their text columns (symbol and date read left-to-right, so their
// headers sit over the start of the data); the other specs right-
// justify their numeric columns. `shares_num` / `weight_num` render
// a float in place (data rows); the `*_str` specs right-justify a
// pre-formatted string (headers and the TUI's pre-rendered cells).
pub const symbol_spec = cp("{{s:<{d}}}", .{symbol_w});
pub const shares_num = cp("{{d:>{d}.1}}", .{shares_w});
pub const weight_num = cp("{{d:>{d}.1}}%", .{weight_w - 1});
pub const shares_str = cp("{{s:>{d}}}", .{shares_w});
pub const price_str = cp("{{s:>{d}}}", .{price_w});
pub const value_str = cp("{{s:>{d}}}", .{value_w});
pub const gainloss_str = cp("{{s:>{d}}}", .{gainloss_w});
pub const weight_str = cp("{{s:>{d}}}", .{weight_w});
pub const date_str = cp("{{s:<{d}}}", .{date_w});
// CLI header + dashed separator (9 columns, see header_labels).
pub const header = " " ++ symbol_spec ++ " " ++ shares_str ++ " " ++ price_str ++
" " ++ price_str ++ " " ++ value_str ++ " " ++ gainloss_str ++
" " ++ weight_str ++ " " ++ date_str ++ " {s}\n";
pub const header_labels = .{ "Symbol", "Shares", "Avg Cost", "Price", "Market Value", "Gain/Loss", "Weight", "Date", "Account" };
pub const separator = " " ++ cp("{{s:->{d}}}", .{symbol_w}) ++
" " ++ cp("{{s:->{d}}}", .{shares_w}) ++
" " ++ cp("{{s:->{d}}}", .{price_w}) ++
" " ++ cp("{{s:->{d}}}", .{price_w}) ++
" " ++ cp("{{s:->{d}}}", .{value_w}) ++
" " ++ cp("{{s:->{d}}}", .{gainloss_w}) ++
" " ++ cp("{{s:->{d}}}", .{weight_w}) ++
" " ++ cp("{{s:->{d}}}", .{date_w}) ++
" " ++ cp("{{s:->{d}}}", .{account_w}) ++ "\n";
pub const separator_fills = .{ "", "", "", "", "", "", "", "", "" };
// CLI TOTAL row: a sum rule spanning Symbol..Weight, then the totals
// row. `total` blanks the symbol / shares / avg-cost cells, right-
// justifies "TOTAL" in the price cell, and renders the market-value
// cell (4 string args + the market-value `{f}`); the caller prints
// the gain/loss and weight cells separately because gain/loss needs
// color.
pub const total_sep = " " ++ cp("{{s:->{d}}}", .{symbol_w}) ++
" " ++ cp("{{s:->{d}}}", .{shares_w}) ++
" " ++ cp("{{s:->{d}}}", .{price_w}) ++
" " ++ cp("{{s:->{d}}}", .{price_w}) ++
" " ++ cp("{{s:->{d}}}", .{value_w}) ++
" " ++ cp("{{s:->{d}}}", .{gainloss_w}) ++
" " ++ cp("{{s:->{d}}}", .{weight_w}) ++ "\n";
pub const total_sep_fills = .{ "", "", "", "", "", "", "" };
pub const total = " " ++ symbol_spec ++ " " ++ shares_str ++ " " ++ price_str ++
" " ++ price_str ++ " {f} ";
};
// Options
/// Column layout for the Options section.