dynamic column widths for portfolios
This commit is contained in:
parent
9609271556
commit
cc5030d70c
5 changed files with 715 additions and 148 deletions
|
|
@ -307,11 +307,22 @@ pub fn display(
|
|||
}
|
||||
}
|
||||
|
||||
// Column headers
|
||||
// Column headers. Column widths fit the rendered content (each
|
||||
// header label is the floor, the widest datum the ceiling); see
|
||||
// views.computeWidths. Computed once and threaded through the rows,
|
||||
// totals, and watchlist so every line aligns.
|
||||
const w = views.computeWidths(
|
||||
summary.allocations,
|
||||
portfolio.lots,
|
||||
summary.total_value,
|
||||
summary.unrealized_gain_loss,
|
||||
watch_symbols,
|
||||
watch_prices,
|
||||
);
|
||||
try out.print("\n", .{});
|
||||
try cli.setFg(out, color, cli.CLR_MUTED);
|
||||
try out.print(pl.header, pl.header_labels);
|
||||
try out.print(pl.separator, pl.separator_fills);
|
||||
try views.writeHeader(out, w);
|
||||
try views.writeSeparator(out, w);
|
||||
try cli.reset(out, color);
|
||||
|
||||
// Position rows with lot detail
|
||||
|
|
@ -343,13 +354,21 @@ pub fn display(
|
|||
}
|
||||
|
||||
if (a.is_manual_price) try cli.setFg(out, color, cli.CLR_WARNING);
|
||||
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(pl.price_w)});
|
||||
try out.print(" {f} ", .{Money.from(a.market_value).padRight(pl.value_w)});
|
||||
try out.writeAll(" ");
|
||||
try views.writeCol(out, a.display_symbol, w.symbol_w, false);
|
||||
try out.writeByte(' ');
|
||||
var shares_buf: [48]u8 = undefined;
|
||||
const shares_str = std.fmt.bufPrint(&shares_buf, "{d:.1}", .{a.shares}) catch "?";
|
||||
try views.writeCol(out, shares_str, w.shares_w, true);
|
||||
try out.writeByte(' ');
|
||||
try out.print("{f}", .{Money.from(a.avg_cost).padRight(w.price_w)});
|
||||
try out.writeByte(' ');
|
||||
try out.print("{f}", .{Money.from(a.current_price).padRight(w.price_w)});
|
||||
try out.writeByte(' ');
|
||||
try out.print("{f}", .{Money.from(a.market_value).padRight(w.value_w)});
|
||||
try out.writeByte(' ');
|
||||
try cli.setGainLoss(out, color, a.unrealized_gain_loss);
|
||||
try out.print("{s}{f}", .{ sign, Money.from(gl_abs).padRight(pl.gainloss_w - 1) });
|
||||
try out.print("{s}{f}", .{ sign, Money.from(gl_abs).padRight(w.gainloss_w - 1) });
|
||||
if (a.is_manual_price) {
|
||||
try cli.setFg(out, color, cli.CLR_WARNING);
|
||||
} else {
|
||||
|
|
@ -383,13 +402,13 @@ pub fn display(
|
|||
if (!has_drip) {
|
||||
// No DRIP: show all individually
|
||||
for (lots_for_sym.items) |lot| {
|
||||
try printLotRow(as_of, out, color, lot, a.current_price);
|
||||
try printLotRow(as_of, out, color, lot, a.current_price, w);
|
||||
}
|
||||
} else {
|
||||
// Show non-DRIP lots individually
|
||||
for (lots_for_sym.items) |lot| {
|
||||
if (!lot.drip) {
|
||||
try printLotRow(as_of, out, color, lot, a.current_price);
|
||||
try printLotRow(as_of, out, color, lot, a.current_price, w);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -409,15 +428,24 @@ pub fn display(
|
|||
}
|
||||
|
||||
// Totals line
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, pl.total_sep, pl.total_sep_fills);
|
||||
try cli.setFg(out, color, cli.CLR_MUTED);
|
||||
try views.writeTotalSeparator(out, w);
|
||||
try cli.reset(out, color);
|
||||
{
|
||||
const gl_abs = if (summary.unrealized_gain_loss >= 0) summary.unrealized_gain_loss else -summary.unrealized_gain_loss;
|
||||
try out.print(pl.total, .{
|
||||
"", "", "", "TOTAL", Money.from(summary.total_value).padRight(pl.value_w),
|
||||
});
|
||||
try out.writeAll(" ");
|
||||
try views.writeCol(out, "", w.symbol_w, false); // blank symbol
|
||||
try out.writeByte(' ');
|
||||
try views.writeCol(out, "", w.shares_w, true); // blank shares
|
||||
try out.writeByte(' ');
|
||||
try views.writeCol(out, "", w.price_w, true); // blank avg cost
|
||||
try out.writeByte(' ');
|
||||
try views.writeCol(out, "TOTAL", w.price_w, true); // "TOTAL" in the price column
|
||||
try out.writeByte(' ');
|
||||
try out.print("{f} ", .{Money.from(summary.total_value).padRight(w.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(pl.gainloss_w - 1),
|
||||
Money.from(gl_abs).padRight(w.gainloss_w - 1),
|
||||
});
|
||||
try out.print(" " ++ pl.weight_str ++ "\n", .{"100.0%"});
|
||||
}
|
||||
|
|
@ -589,7 +617,11 @@ pub fn display(
|
|||
std.fmt.bufPrint(&price_str, "{f}", .{Money.from(close)}) catch "$?"
|
||||
else
|
||||
"--";
|
||||
try out.print(" " ++ pl.symbol_spec ++ " " ++ pl.price_str ++ "\n", .{ sym, ps });
|
||||
try out.writeAll(" ");
|
||||
try views.writeCol(out, sym, w.symbol_w, false);
|
||||
try out.writeByte(' ');
|
||||
try views.writeCol(out, ps, w.price_w, true);
|
||||
try out.writeByte('\n');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -602,7 +634,7 @@ pub fn display(
|
|||
try out.print("\n", .{});
|
||||
}
|
||||
|
||||
pub fn printLotRow(as_of: zfin.Date, out: *std.Io.Writer, color: bool, lot: zfin.Lot, current_price: f64) !void {
|
||||
pub fn printLotRow(as_of: zfin.Date, out: *std.Io.Writer, color: bool, lot: zfin.Lot, current_price: f64, w: views.PositionsWidths) !void {
|
||||
const indicator = fmt.capitalGainsIndicator(as_of, lot.open_date);
|
||||
const status_str: []const u8 = if (lot.isOpen(as_of)) "open" else "closed";
|
||||
const acct_col: []const u8 = lot.account orelse "";
|
||||
|
|
@ -612,11 +644,31 @@ 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, " " ++ 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(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 });
|
||||
// Muted run: prefix (lots are indented one level past positions)
|
||||
// through the market-value cell.
|
||||
try cli.setFg(out, color, cli.CLR_MUTED);
|
||||
try out.writeAll(" ");
|
||||
try views.writeCol(out, status_str, w.symbol_w, false);
|
||||
try out.writeByte(' ');
|
||||
var shares_buf: [48]u8 = undefined;
|
||||
const shares_str = std.fmt.bufPrint(&shares_buf, "{d:.1}", .{lot.shares}) catch "?";
|
||||
try views.writeCol(out, shares_str, w.shares_w, true);
|
||||
try out.writeByte(' ');
|
||||
try out.print("{f}", .{Money.from(lot.open_price).padRight(w.price_w)});
|
||||
try out.writeByte(' ');
|
||||
try views.writeCol(out, "", w.price_w, true); // blank current-price cell
|
||||
try out.writeByte(' ');
|
||||
try out.print("{f}", .{Money.from(lot.shares * use_price).padRight(w.value_w)});
|
||||
try out.writeByte(' ');
|
||||
try cli.reset(out, color);
|
||||
// Colored gain/loss cell.
|
||||
try cli.printGainLoss(out, color, gl, "{s}{f}", .{ lot_sign, Money.from(lot_gl_abs).padRight(w.gainloss_w - 1) });
|
||||
// Muted run: blank weight cell, then date / indicator / account.
|
||||
try cli.setFg(out, color, cli.CLR_MUTED);
|
||||
try out.writeByte(' ');
|
||||
try views.writeCol(out, "", w.weight_w, true);
|
||||
try out.print(" {f} {s} {s}\n", .{ lot.open_date, indicator, acct_col });
|
||||
try cli.reset(out, color);
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────
|
||||
|
|
@ -749,11 +801,13 @@ test "display widens columns for large crypto values" {
|
|||
|
||||
// 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);
|
||||
// Fit-to-content: the Symbol column sizes to the longest symbol
|
||||
// (DOGE-USD = 8 cols) and Shares to "10000.0" (7 cols), so the full
|
||||
// symbol renders followed by exactly the 1-col separator and then
|
||||
// the right-justified Shares value - no truncation, no wasted
|
||||
// padding. (The old fixed 10-wide Symbol column left 2 trailing pad
|
||||
// spaces here; fit-to-content removes them.)
|
||||
try testing.expect(std.mem.indexOf(u8, out, "DOGE-USD 10000.0") != 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);
|
||||
|
|
@ -806,6 +860,66 @@ test "display widens columns for large crypto values" {
|
|||
try testing.expectEqual(hdr_acct.?, row_acct.?);
|
||||
}
|
||||
|
||||
test "display tightens columns for a small portfolio" {
|
||||
// Counterpart to the crypto test: small share counts and prices mean
|
||||
// every dynamic column collapses to its header-label minimum, so the
|
||||
// table is compact rather than padded out for hypothetical large
|
||||
// values. The TOTAL row still aligns with the data row.
|
||||
var buf: [8192]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
|
||||
var lots = [_]zfin.Lot{
|
||||
.{ .symbol = "IBM", .shares = 10, .open_date = zfin.Date.fromYmd(2024, 1, 2), .open_price = 150.0, .account = "Sample Account" },
|
||||
};
|
||||
var portfolio = testPortfolio(&lots);
|
||||
|
||||
var positions = [_]zfin.Position{
|
||||
.{ .symbol = "IBM", .shares = 10, .avg_cost = 150.0, .total_cost = 1500.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
|
||||
};
|
||||
|
||||
var allocs = [_]zfin.valuation.Allocation{
|
||||
.{ .symbol = "IBM", .display_symbol = "IBM", .shares = 10, .avg_cost = 150.0, .current_price = 155.0, .market_value = 1550.0, .cost_basis = 1500.0, .weight = 1.0, .unrealized_gain_loss = 50.0, .unrealized_return = 0.0333 },
|
||||
};
|
||||
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, "small.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices, zfin.Date.fromYmd(2026, 5, 8), .rollup);
|
||||
const out = w.buffered();
|
||||
|
||||
// All dynamic columns are at their header-label minimums, so the
|
||||
// labels sit flush against each other with a single separator space:
|
||||
// "Symbol"(6) + " " + "Shares"(6) + " " + "Avg Cost"(8). With the old
|
||||
// fixed widths (Symbol=10, Shares=12) there would be many spaces here,
|
||||
// so this pins the "tighten, don't waste space" behavior.
|
||||
try testing.expect(std.mem.indexOf(u8, out, "Symbol Shares Avg Cost") != null);
|
||||
|
||||
// Data row market value aligns with the TOTAL row market value.
|
||||
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, "$1,550.00") orelse continue;
|
||||
if (std.mem.indexOf(u8, line, "IBM") != 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.?);
|
||||
|
||||
// No ANSI codes when color is disabled.
|
||||
try testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
|
||||
}
|
||||
|
||||
test "display with watchlist" {
|
||||
var buf: [8192]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
|
|
|
|||
15
src/tui.zig
15
src/tui.zig
|
|
@ -47,7 +47,12 @@ const ascii_g = blk: {
|
|||
/// The indicator (▲/▼, 3 bytes, 1 display column) replaces a padding space so total
|
||||
/// display width stays constant. Indicator always appears on the left side.
|
||||
/// `left` controls text alignment (left-aligned vs right-aligned).
|
||||
pub fn colLabel(buf: []u8, name: []const u8, comptime col_width: usize, left: bool, indicator: ?[]const u8) []const u8 {
|
||||
pub fn colLabel(buf: []u8, name: []const u8, col_width: usize, left: bool, indicator: ?[]const u8) []const u8 {
|
||||
// `col_width` is dynamic (computed per render), so guard against a
|
||||
// width that exceeds the caller's buffer rather than overflowing.
|
||||
// Falling back to the unpadded name is safe; callers size buffers
|
||||
// generously so this only trips for pathological widths.
|
||||
if (col_width > buf.len) return name;
|
||||
const ind = indicator orelse {
|
||||
// No indicator: plain padded label
|
||||
if (left) {
|
||||
|
|
@ -2746,6 +2751,14 @@ test "colLabel plain left-aligned" {
|
|||
try testing.expectEqual(@as(usize, 10), result.len);
|
||||
}
|
||||
|
||||
test "colLabel: width exceeding the buffer falls back to the unpadded name" {
|
||||
// Dynamic column widths can exceed a caller's buffer; colLabel must
|
||||
// return the name unchanged rather than overflow.
|
||||
var buf: [8]u8 = undefined;
|
||||
const result = colLabel(&buf, "Name", 100, true, null);
|
||||
try testing.expectEqualStrings("Name", result);
|
||||
}
|
||||
|
||||
test "colLabel plain right-aligned" {
|
||||
var buf: [32]u8 = undefined;
|
||||
const result = colLabel(&buf, "Price", 10, false, null);
|
||||
|
|
|
|||
|
|
@ -4,7 +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).
|
||||
/// Holdings-table layout: fixed knobs (weight/date specs) + minimum
|
||||
/// widths. The dynamic widths come from `views.computeWidths`.
|
||||
const pl = views.PositionsLayout;
|
||||
const theme = @import("theme.zig");
|
||||
const tui = @import("../tui.zig");
|
||||
|
|
@ -16,13 +17,13 @@ const colLabel = tui.colLabel;
|
|||
|
||||
// Portfolio column layout (display columns).
|
||||
// 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.
|
||||
// The non-prefix column widths are computed per render by
|
||||
// views.computeWidths (fit-to-content: header-label floor, widest-datum
|
||||
// ceiling) and cached in State.col_widths; each column adds a 1-col
|
||||
// separator space. The header, data rows, and the click-to-sort
|
||||
// hit-test offsets (`columnOffsets`) all read those same widths, so
|
||||
// they can't drift apart.
|
||||
const prefix_cols: usize = 4;
|
||||
const sw: usize = pl.symbol_w;
|
||||
|
||||
// ── Portfolio-specific types ──────────────────────────────────
|
||||
|
||||
|
|
@ -278,6 +279,12 @@ pub const State = struct {
|
|||
/// captured when streaming starts so the per-tick snapshot has
|
||||
/// stable key strings. Freed (and emptied) when streaming stops.
|
||||
stream_syms: [][]const u8 = &.{},
|
||||
/// Holdings-table column widths from the most recent render
|
||||
/// (`views.computeWidths`). Cached here so `handleMouse`'s
|
||||
/// click-to-sort hit test uses the exact same column boundaries the
|
||||
/// last frame drew. Defaults to the per-column minimums, which is a
|
||||
/// valid layout for the pre-first-render case.
|
||||
col_widths: views.PositionsWidths = .{},
|
||||
};
|
||||
|
||||
// ── Tab framework contract ────────────────────────────────────
|
||||
|
|
@ -678,22 +685,23 @@ pub const tab = struct {
|
|||
// Click on the column-header row -> sort by that column.
|
||||
if (state.header_lines > 0 and content_row == state.header_lines - 1) {
|
||||
const col: usize = @intCast(mouse.col);
|
||||
const off = columnOffsets(state.col_widths);
|
||||
const new_field: ?PortfolioSortField =
|
||||
if (col < col_end_symbol)
|
||||
if (col < off.symbol)
|
||||
.symbol
|
||||
else if (col < col_end_shares)
|
||||
else if (col < off.shares)
|
||||
.shares
|
||||
else if (col < col_end_avg_cost)
|
||||
else if (col < off.avg_cost)
|
||||
.avg_cost
|
||||
else if (col < col_end_price)
|
||||
else if (col < off.price)
|
||||
.price
|
||||
else if (col < col_end_market_value)
|
||||
else if (col < off.market_value)
|
||||
.market_value
|
||||
else if (col < col_end_gain_loss)
|
||||
else if (col < off.gain_loss)
|
||||
.gain_loss
|
||||
else if (col < col_end_weight)
|
||||
else if (col < off.weight)
|
||||
.weight
|
||||
else if (col < col_end_date)
|
||||
else if (col < off.date)
|
||||
null // Date (not sortable)
|
||||
else
|
||||
.account;
|
||||
|
|
@ -780,21 +788,46 @@ fn toggleExpandAtCursor(state: *State, app: *App) void {
|
|||
}
|
||||
}
|
||||
|
||||
/// Cumulative column end positions for click-to-sort hit testing.
|
||||
/// 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;
|
||||
/// Cumulative column end positions for click-to-sort hit testing and
|
||||
/// the gain/loss alt-style range. Each offset is the previous column's
|
||||
/// end plus this column's content width plus the 1-col separator space,
|
||||
/// so the hit-test grid stays locked to the rendered rows. Computed
|
||||
/// from the same `PositionsWidths` the rows are drawn with.
|
||||
const ColumnOffsets = struct {
|
||||
symbol: usize,
|
||||
shares: usize,
|
||||
avg_cost: usize,
|
||||
price: usize,
|
||||
market_value: usize,
|
||||
gain_loss: usize,
|
||||
weight: usize,
|
||||
date: usize,
|
||||
/// Gain/loss column start (== market_value end); used for alt-style
|
||||
/// coloring of the gain/loss cell.
|
||||
gl_start: usize,
|
||||
};
|
||||
|
||||
// Gain/loss column start position (used for alt-style coloring)
|
||||
const gl_col_start: usize = col_end_market_value;
|
||||
fn columnOffsets(w: views.PositionsWidths) ColumnOffsets {
|
||||
const symbol = prefix_cols + w.symbol_w + 1;
|
||||
const shares = symbol + w.shares_w + 1;
|
||||
const avg_cost = shares + w.price_w + 1;
|
||||
const price = avg_cost + w.price_w + 1;
|
||||
const market_value = price + w.value_w + 1;
|
||||
const gain_loss = market_value + w.gainloss_w + 1;
|
||||
const weight = gain_loss + w.weight_w + 1;
|
||||
const date = weight + w.date_w + 1;
|
||||
return .{
|
||||
.symbol = symbol,
|
||||
.shares = shares,
|
||||
.avg_cost = avg_cost,
|
||||
.price = price,
|
||||
.market_value = market_value,
|
||||
.gain_loss = gain_loss,
|
||||
.weight = weight,
|
||||
.date = date,
|
||||
.gl_start = market_value,
|
||||
};
|
||||
}
|
||||
|
||||
/// Map a semantic StyleIntent to a platform-specific vaxis style.
|
||||
fn mapIntent(th: theme.Theme, intent: fmt.StyleIntent) vaxis.Style {
|
||||
|
|
@ -1657,26 +1690,54 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va
|
|||
// Empty line before header
|
||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
|
||||
// Fit-to-content column widths for this frame. Watch symbols come
|
||||
// from the already-built rows, so the account filter (which hides
|
||||
// the watchlist) is reflected automatically. Cache the widths in
|
||||
// State so handleMouse's click-to-sort hit test uses the exact same
|
||||
// column boundaries this frame draws. (Under an account filter the
|
||||
// unfiltered allocation values may slightly over-size a column;
|
||||
// that only ever over-pads, never misaligns - see computeWidths.)
|
||||
const cw = blk: {
|
||||
if (app.portfolio.summary) |s| {
|
||||
const lots: []const zfin.Lot = if (app.portfolio.file) |pf| pf.lots else &.{};
|
||||
var watch_syms: std.ArrayList([]const u8) = .empty;
|
||||
for (state.rows.items) |row| {
|
||||
if (row.kind == .watchlist) try watch_syms.append(arena, row.symbol);
|
||||
}
|
||||
break :blk views.computeWidths(
|
||||
s.allocations,
|
||||
lots,
|
||||
s.total_value,
|
||||
s.unrealized_gain_loss,
|
||||
watch_syms.items,
|
||||
app.portfolio.watchlist_prices,
|
||||
);
|
||||
} else break :blk views.PositionsWidths{};
|
||||
};
|
||||
state.col_widths = cw;
|
||||
const off = columnOffsets(cw);
|
||||
|
||||
// Column header (4-char prefix to match arrow(2)+star(2) in data rows)
|
||||
// Active sort column gets a sort indicator within the column width
|
||||
const sf = state.sort_field;
|
||||
const si = state.sort_dir.indicator();
|
||||
// Build column labels with indicator embedded in padding
|
||||
// Left-aligned cols: "Name▲ " Right-aligned cols: " ▼Price"
|
||||
var sym_hdr_buf: [16]u8 = undefined;
|
||||
var shr_hdr_buf: [16]u8 = undefined;
|
||||
var avg_hdr_buf: [16]u8 = undefined;
|
||||
var prc_hdr_buf: [16]u8 = undefined;
|
||||
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", 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);
|
||||
// Buffers are generous (64) because column widths are now dynamic.
|
||||
var sym_hdr_buf: [64]u8 = undefined;
|
||||
var shr_hdr_buf: [64]u8 = undefined;
|
||||
var avg_hdr_buf: [64]u8 = undefined;
|
||||
var prc_hdr_buf: [64]u8 = undefined;
|
||||
var mv_hdr_buf: [64]u8 = undefined;
|
||||
var gl_hdr_buf: [64]u8 = undefined;
|
||||
var wt_hdr_buf: [64]u8 = undefined;
|
||||
const sym_hdr = colLabel(&sym_hdr_buf, "Symbol", cw.symbol_w, true, if (sf == .symbol) si else null);
|
||||
const shr_hdr = colLabel(&shr_hdr_buf, "Shares", cw.shares_w, false, if (sf == .shares) si else null);
|
||||
const avg_hdr = colLabel(&avg_hdr_buf, "Avg Cost", cw.price_w, false, if (sf == .avg_cost) si else null);
|
||||
const prc_hdr = colLabel(&prc_hdr_buf, "Price", cw.price_w, false, if (sf == .price) si else null);
|
||||
const mv_hdr = colLabel(&mv_hdr_buf, "Market Value", cw.value_w, false, if (sf == .market_value) si else null);
|
||||
const gl_hdr = colLabel(&gl_hdr_buf, "Gain/Loss", cw.gainloss_w, false, if (sf == .gain_loss) si else null);
|
||||
const wt_hdr = colLabel(&wt_hdr_buf, "Weight", cw.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} " ++ pl.date_str ++ " {s}{s}", .{
|
||||
|
|
@ -1760,8 +1821,29 @@ 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}" ++ 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,
|
||||
// Pad each dynamic cell to this frame's column
|
||||
// widths (weight + date stay fixed-spec). Symbol
|
||||
// is left-justified; the numeric cells right.
|
||||
var sym_buf: [64]u8 = undefined;
|
||||
const sym_cell = blk: {
|
||||
const sym_src = std.fmt.bufPrint(&sym_buf, "{s}", .{a.display_symbol}) catch a.display_symbol;
|
||||
break :blk fmt.padRightToCols(&sym_buf, sym_src, cw.symbol_w);
|
||||
};
|
||||
var shr_raw_buf: [48]u8 = undefined;
|
||||
const shr_raw = std.fmt.bufPrint(&shr_raw_buf, "{d:.1}", .{display_shares}) catch "?";
|
||||
var shr_buf: [64]u8 = undefined;
|
||||
const shr_cell = fmt.padLeftToCols(&shr_buf, shr_raw, cw.shares_w);
|
||||
var cost_pad: [64]u8 = undefined;
|
||||
const cost_cell = fmt.padLeftToCols(&cost_pad, cost_str, cw.price_w);
|
||||
var prc_pad: [64]u8 = undefined;
|
||||
const prc_cell = fmt.padLeftToCols(&prc_pad, price_str, cw.price_w);
|
||||
var mv_pad: [64]u8 = undefined;
|
||||
const mv_cell = fmt.padLeftToCols(&mv_pad, mv_str, cw.value_w);
|
||||
var gl_pad: [64]u8 = undefined;
|
||||
const gl_cell = fmt.padLeftToCols(&gl_pad, pnl_str, cw.gainloss_w);
|
||||
|
||||
const text = try std.fmt.allocPrint(arena, "{s}{s}{s} {s} {s} {s} {s} {s} " ++ pl.weight_num ++ " " ++ pl.date_str ++ " {s}", .{
|
||||
arrow, star, sym_cell, shr_cell, cost_cell, prc_cell, mv_cell, gl_cell, display_weight * 100.0, date_col, acct_col,
|
||||
});
|
||||
|
||||
// base: neutral text for main cols, green/red only for gain/loss col
|
||||
|
|
@ -1773,8 +1855,8 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va
|
|||
.text = text,
|
||||
.style = base_style,
|
||||
.alt_style = gl_style,
|
||||
.alt_start = gl_col_start,
|
||||
.alt_end = gl_col_start + pl.gainloss_w,
|
||||
.alt_start = off.gl_start,
|
||||
.alt_end = off.gl_start + cw.gainloss_w,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1808,8 +1890,26 @@ 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, " " ++ 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,
|
||||
|
||||
var lot_sym_buf: [64]u8 = undefined;
|
||||
const lot_sym_cell = blk: {
|
||||
const sym_src = std.fmt.bufPrint(&lot_sym_buf, "{s}", .{status_str}) catch status_str;
|
||||
break :blk fmt.padRightToCols(&lot_sym_buf, sym_src, cw.symbol_w);
|
||||
};
|
||||
var lot_shr_raw: [48]u8 = undefined;
|
||||
const lot_shr_raw_s = std.fmt.bufPrint(&lot_shr_raw, "{d:.1}", .{lot.shares}) catch "?";
|
||||
var lot_shr_buf: [64]u8 = undefined;
|
||||
const lot_shr_cell = fmt.padLeftToCols(&lot_shr_buf, lot_shr_raw_s, cw.shares_w);
|
||||
var lot_cost_pad: [64]u8 = undefined;
|
||||
const lot_cost_cell = fmt.padLeftToCols(&lot_cost_pad, lot_price_str, cw.price_w);
|
||||
var lot_prc_pad: [64]u8 = undefined;
|
||||
const lot_prc_cell = fmt.padLeftToCols(&lot_prc_pad, "", cw.price_w); // blank current-price cell
|
||||
var lot_mv_pad: [64]u8 = undefined;
|
||||
const lot_mv_cell = fmt.padLeftToCols(&lot_mv_pad, lot_mv_str, cw.value_w);
|
||||
var lot_gl_pad: [64]u8 = undefined;
|
||||
const lot_gl_cell = fmt.padLeftToCols(&lot_gl_pad, lot_gl_str, cw.gainloss_w);
|
||||
const text = try std.fmt.allocPrint(arena, " {s} {s} {s} {s} {s} {s} " ++ pl.weight_str ++ " " ++ pl.date_str ++ " {s}", .{
|
||||
lot_sym_cell, lot_shr_cell, lot_cost_cell, lot_prc_cell, lot_mv_cell, lot_gl_cell, "", lot_date_col, acct_col,
|
||||
});
|
||||
const base_style = if (is_cursor) th.selectStyle() else th.mutedStyle();
|
||||
const gl_col_style = if (is_cursor) th.selectStyle() else if (lot_positive) th.positiveStyle() else th.negativeStyle();
|
||||
|
|
@ -1817,8 +1917,8 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va
|
|||
.text = text,
|
||||
.style = base_style,
|
||||
.alt_style = gl_col_style,
|
||||
.alt_start = gl_col_start,
|
||||
.alt_end = gl_col_start + pl.gainloss_w,
|
||||
.alt_start = off.gl_start,
|
||||
.alt_end = off.gl_start + cw.gainloss_w,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
@ -1829,8 +1929,23 @@ 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}" ++ 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", "",
|
||||
var w_sym_buf: [64]u8 = undefined;
|
||||
const w_sym_cell = blk: {
|
||||
const sym_src = std.fmt.bufPrint(&w_sym_buf, "{s}", .{row.symbol}) catch row.symbol;
|
||||
break :blk fmt.padRightToCols(&w_sym_buf, sym_src, cw.symbol_w);
|
||||
};
|
||||
var w_shr_buf: [64]u8 = undefined;
|
||||
const w_shr_cell = fmt.padLeftToCols(&w_shr_buf, "--", cw.shares_w);
|
||||
var w_avg_buf: [64]u8 = undefined;
|
||||
const w_avg_cell = fmt.padLeftToCols(&w_avg_buf, "--", cw.price_w);
|
||||
var w_prc_buf: [64]u8 = undefined;
|
||||
const w_prc_cell = fmt.padLeftToCols(&w_prc_buf, ps, cw.price_w);
|
||||
var w_mv_buf: [64]u8 = undefined;
|
||||
const w_mv_cell = fmt.padLeftToCols(&w_mv_buf, "--", cw.value_w);
|
||||
var w_gl_buf: [64]u8 = undefined;
|
||||
const w_gl_cell = fmt.padLeftToCols(&w_gl_buf, "--", cw.gainloss_w);
|
||||
const text = try std.fmt.allocPrint(arena, " {s}{s} {s} {s} {s} {s} {s} " ++ pl.weight_str ++ " " ++ pl.date_str, .{
|
||||
star2, w_sym_cell, w_shr_cell, w_avg_cell, w_prc_cell, w_mv_cell, w_gl_cell, "watch", "",
|
||||
});
|
||||
const row_style = if (is_cursor) th.selectStyle() else th.contentStyle();
|
||||
try lines.append(arena, .{ .text = text, .style = row_style });
|
||||
|
|
@ -2711,6 +2826,23 @@ test "ensureCursorVisible: zero visible height is a no-op for the lower bound" {
|
|||
try testing.expectEqual(@as(usize, 0), scroll);
|
||||
}
|
||||
|
||||
test "columnOffsets: cumulative boundaries derived from the column widths" {
|
||||
// gl_start == market_value end, and each boundary is the prior one
|
||||
// plus the column's width plus a 1-col separator. These offsets back
|
||||
// the click-to-sort hit test and the gain/loss alt-style range.
|
||||
const w: views.PositionsWidths = .{ .symbol_w = 8, .shares_w = 7, .price_w = 10, .value_w = 15, .gainloss_w = 14 };
|
||||
const off = columnOffsets(w);
|
||||
try testing.expectEqual(prefix_cols + w.symbol_w + 1, off.symbol);
|
||||
try testing.expectEqual(off.symbol + w.shares_w + 1, off.shares);
|
||||
try testing.expectEqual(off.shares + w.price_w + 1, off.avg_cost);
|
||||
try testing.expectEqual(off.avg_cost + w.price_w + 1, off.price);
|
||||
try testing.expectEqual(off.price + w.value_w + 1, off.market_value);
|
||||
try testing.expectEqual(off.market_value + w.gainloss_w + 1, off.gain_loss);
|
||||
try testing.expectEqual(off.gain_loss + w.weight_w + 1, off.weight);
|
||||
try testing.expectEqual(off.weight + w.date_w + 1, off.date);
|
||||
try testing.expectEqual(off.market_value, off.gl_start);
|
||||
}
|
||||
|
||||
test "gatherStreamSymbols: held first, watchlist deduped against held" {
|
||||
const a = testing.allocator;
|
||||
const held = [_][]const u8{ "AAPL", "SPY" };
|
||||
|
|
|
|||
|
|
@ -1796,8 +1796,7 @@ fn buildHeaderLine(
|
|||
if (sort_field == col_sf) break :blk sort_dir.indicator();
|
||||
break :blk null;
|
||||
};
|
||||
// `comptime` widths required by colLabel.
|
||||
const w = comptime col.width();
|
||||
const w = col.width();
|
||||
var buf: [64]u8 = undefined;
|
||||
const left_align = (col == .symbol or col == .sector);
|
||||
const lbl = tui.colLabel(&buf, col.header(), w, left_align, ind);
|
||||
|
|
|
|||
|
|
@ -9,87 +9,258 @@ const Lot = @import("../models/portfolio.zig").Lot;
|
|||
const Date = @import("../Date.zig");
|
||||
const fmt = @import("../format.zig");
|
||||
const Money = @import("../Money.zig");
|
||||
const Allocation = @import("../analytics/valuation.zig").Allocation;
|
||||
|
||||
// ── 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.
|
||||
/// Fixed knobs for the main holdings table. The numeric column widths
|
||||
/// are NOT fixed any more: they're computed per render by
|
||||
/// `computeWidths` so the table fits its content (each column's header
|
||||
/// label is the floor; the widest rendered datum is the ceiling). This
|
||||
/// struct holds only the parts that never vary:
|
||||
///
|
||||
/// 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.
|
||||
/// - The two columns that never need to grow: Weight (bounded to
|
||||
/// "100.0%") and Date (always "YYYY-MM-DD" plus a one-col ST/LT
|
||||
/// indicator).
|
||||
/// - The minimum width of each dynamic column, which equals the
|
||||
/// width of that column's header label so the label is never
|
||||
/// truncated (even for an empty portfolio).
|
||||
/// - The fixed weight/date format specs and the header labels.
|
||||
///
|
||||
/// `PositionsWidths` (via `computeWidths`) is the single source of
|
||||
/// truth for the dynamic widths. It's consumed by the CLI
|
||||
/// (commands/portfolio.zig), the TUI rows + header
|
||||
/// (tui/portfolio_tab.zig), and the TUI click-to-sort hit-test offsets,
|
||||
/// so they all size identically and can't drift apart.
|
||||
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
|
||||
// Fixed columns (never grow). The 1-col separating space between
|
||||
// columns is added at layout time, not included here.
|
||||
pub const weight_w = 8; // "NNN.N%"
|
||||
pub const date_w = 13;
|
||||
pub const date_w = 13; // "YYYY-MM-DD" + " " + ST/LT indicator
|
||||
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});
|
||||
// Minimum width of each dynamic column = its header label width, so
|
||||
// the label never truncates. (min_symbol_w also covers the lot-row
|
||||
// status text "closed".)
|
||||
pub const min_symbol_w = "Symbol".len; // 6
|
||||
pub const min_shares_w = "Shares".len; // 6
|
||||
pub const min_price_w = "Avg Cost".len; // 8 (wider of "Avg Cost" / "Price")
|
||||
pub const min_value_w = "Market Value".len; // 12
|
||||
pub const min_gainloss_w = "Gain/Loss".len; // 9
|
||||
|
||||
// Fixed-column format specs (weight + date never vary). `weight_num`
|
||||
// renders a float in place (data rows); `weight_str` right-justifies
|
||||
// a pre-formatted string (headers, the TUI's pre-rendered cells);
|
||||
// `date_str` left-justifies the date text.
|
||||
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} ";
|
||||
};
|
||||
|
||||
/// Per-render widths for the holdings table's dynamic columns, plus the
|
||||
/// two fixed ones mirrored in so callers have one place that answers
|
||||
/// "how wide is column X". Field defaults are the per-column minimums,
|
||||
/// so a default-initialized value (e.g. a TUI state's cached widths
|
||||
/// before the first render) still yields a valid, non-truncating
|
||||
/// layout. Populate via `computeWidths`.
|
||||
pub const PositionsWidths = struct {
|
||||
symbol_w: usize = PositionsLayout.min_symbol_w,
|
||||
shares_w: usize = PositionsLayout.min_shares_w,
|
||||
price_w: usize = PositionsLayout.min_price_w,
|
||||
value_w: usize = PositionsLayout.min_value_w,
|
||||
gainloss_w: usize = PositionsLayout.min_gainloss_w,
|
||||
weight_w: usize = PositionsLayout.weight_w,
|
||||
date_w: usize = PositionsLayout.date_w,
|
||||
};
|
||||
|
||||
/// Compute the holdings-table column widths from the data that will be
|
||||
/// rendered: every allocation row, every stock lot row (lot
|
||||
/// `open_price` lands in the Avg Cost column, and a single lot's
|
||||
/// gain/loss can exceed the netted position/total gain/loss when lots
|
||||
/// have opposite signs, so both must be observed), the TOTAL row, and
|
||||
/// the watchlist rows (which share the Symbol / Price columns). Each
|
||||
/// width starts at its header-label minimum and grows to fit.
|
||||
///
|
||||
/// `watch_prices` is keyed by symbol; absent prices simply don't widen
|
||||
/// the Price column. Pass an empty `watch_syms` to exclude the
|
||||
/// watchlist (the TUI hides it under an account filter).
|
||||
///
|
||||
/// Note: callers that show a filtered subset (the TUI account filter)
|
||||
/// may pass the full, unfiltered allocations here. That only ever
|
||||
/// over-estimates a column (a hidden wide row can't be under-padded),
|
||||
/// so alignment stays correct - the filtered view is at worst slightly
|
||||
/// roomier than strictly necessary.
|
||||
pub fn computeWidths(
|
||||
allocations: []const Allocation,
|
||||
lots: []const Lot,
|
||||
total_value: f64,
|
||||
total_gl: f64,
|
||||
watch_syms: []const []const u8,
|
||||
watch_prices: ?std.StringHashMap(f64),
|
||||
) PositionsWidths {
|
||||
var w: PositionsWidths = .{};
|
||||
|
||||
for (allocations) |a| {
|
||||
w.symbol_w = @max(w.symbol_w, fmt.displayCols(a.display_symbol));
|
||||
w.shares_w = @max(w.shares_w, sharesCols(a.shares));
|
||||
w.price_w = @max(w.price_w, moneyCols(a.avg_cost));
|
||||
w.price_w = @max(w.price_w, moneyCols(a.current_price));
|
||||
w.value_w = @max(w.value_w, moneyCols(a.market_value));
|
||||
w.gainloss_w = @max(w.gainloss_w, gainLossCols(a.unrealized_gain_loss));
|
||||
}
|
||||
|
||||
for (lots) |lot| {
|
||||
if (lot.security_type != .stock) continue;
|
||||
w.shares_w = @max(w.shares_w, sharesCols(lot.shares));
|
||||
w.price_w = @max(w.price_w, moneyCols(lot.open_price));
|
||||
const use_price = lot.close_price orelse currentPriceFor(allocations, lot.priceSymbol());
|
||||
w.value_w = @max(w.value_w, moneyCols(lot.shares * use_price));
|
||||
w.gainloss_w = @max(w.gainloss_w, gainLossCols(lot.shares * (use_price - lot.open_price)));
|
||||
}
|
||||
|
||||
w.value_w = @max(w.value_w, moneyCols(total_value));
|
||||
w.gainloss_w = @max(w.gainloss_w, gainLossCols(total_gl));
|
||||
|
||||
for (watch_syms) |sym| {
|
||||
w.symbol_w = @max(w.symbol_w, fmt.displayCols(sym));
|
||||
if (watch_prices) |wp| {
|
||||
if (wp.get(sym)) |price| w.price_w = @max(w.price_w, moneyCols(price));
|
||||
}
|
||||
}
|
||||
|
||||
return w;
|
||||
}
|
||||
|
||||
/// Display columns of the default Money rendering of `amount`
|
||||
/// ("$1,234.56"). Money output is ASCII, so byte length == columns.
|
||||
fn moneyCols(amount: f64) usize {
|
||||
var buf: [48]u8 = undefined;
|
||||
var w = std.Io.Writer.fixed(&buf);
|
||||
// 48 bytes covers any realistic dollar amount; the fixed writer
|
||||
// returns WriteFailed rather than overflowing, in which case we
|
||||
// fall back to the buffer length (an over-estimate, never an
|
||||
// under-pad).
|
||||
Money.from(amount).format(&w) catch return buf.len;
|
||||
return w.buffered().len;
|
||||
}
|
||||
|
||||
/// Display columns of the shares rendering ("{d:.1}").
|
||||
fn sharesCols(shares: f64) usize {
|
||||
var buf: [48]u8 = undefined;
|
||||
const s = std.fmt.bufPrint(&buf, "{d:.1}", .{shares}) catch return buf.len;
|
||||
return s.len;
|
||||
}
|
||||
|
||||
/// Display columns of a gain/loss cell: one sign char + the money
|
||||
/// magnitude.
|
||||
fn gainLossCols(amount: f64) usize {
|
||||
return 1 + moneyCols(if (amount < 0) -amount else amount);
|
||||
}
|
||||
|
||||
/// Current price for `symbol` from the allocations (linear scan;
|
||||
/// portfolios are small). Returns 0 when absent, which yields a
|
||||
/// trivially small cell that widens nothing.
|
||||
fn currentPriceFor(allocations: []const Allocation, symbol: []const u8) f64 {
|
||||
for (allocations) |a| {
|
||||
if (std.mem.eql(u8, a.symbol, symbol)) return a.current_price;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Write `content` into a `width`-column field: right-justified (spaces
|
||||
/// before) when `right`, else left-justified (spaces after).
|
||||
/// Display-column aware. The streaming analogue of
|
||||
/// `format.padLeftToCols` / `padRightToCols`, used by the CLI renderer
|
||||
/// (which prints straight to the output writer) and by the header /
|
||||
/// separator writers below.
|
||||
pub fn writeCol(out: *std.Io.Writer, content: []const u8, width: usize, right: bool) std.Io.Writer.Error!void {
|
||||
const cols = fmt.displayCols(content);
|
||||
const pad = if (cols >= width) 0 else width - cols;
|
||||
if (right) {
|
||||
try out.splatByteAll(' ', pad);
|
||||
try out.writeAll(content);
|
||||
} else {
|
||||
try out.writeAll(content);
|
||||
try out.splatByteAll(' ', pad);
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the CLI holdings-table column header (Symbol .. Account) sized
|
||||
/// to `w`. Symbol and Date read left-to-right so their labels sit over
|
||||
/// the start of the data; the numeric columns are right-justified.
|
||||
pub fn writeHeader(out: *std.Io.Writer, w: PositionsWidths) std.Io.Writer.Error!void {
|
||||
try out.writeAll(" ");
|
||||
try writeCol(out, "Symbol", w.symbol_w, false);
|
||||
try out.writeByte(' ');
|
||||
try writeCol(out, "Shares", w.shares_w, true);
|
||||
try out.writeByte(' ');
|
||||
try writeCol(out, "Avg Cost", w.price_w, true);
|
||||
try out.writeByte(' ');
|
||||
try writeCol(out, "Price", w.price_w, true);
|
||||
try out.writeByte(' ');
|
||||
try writeCol(out, "Market Value", w.value_w, true);
|
||||
try out.writeByte(' ');
|
||||
try writeCol(out, "Gain/Loss", w.gainloss_w, true);
|
||||
try out.writeByte(' ');
|
||||
try writeCol(out, "Weight", w.weight_w, true);
|
||||
try out.writeByte(' ');
|
||||
try writeCol(out, "Date", w.date_w, false);
|
||||
try out.writeAll(" Account\n");
|
||||
}
|
||||
|
||||
/// Write the CLI dashed separator under the header (9 column rules,
|
||||
/// matching `writeHeader`).
|
||||
pub fn writeSeparator(out: *std.Io.Writer, w: PositionsWidths) std.Io.Writer.Error!void {
|
||||
try out.writeAll(" ");
|
||||
try writeRule(out, w.symbol_w);
|
||||
try out.writeByte(' ');
|
||||
try writeRule(out, w.shares_w);
|
||||
try out.writeByte(' ');
|
||||
try writeRule(out, w.price_w);
|
||||
try out.writeByte(' ');
|
||||
try writeRule(out, w.price_w);
|
||||
try out.writeByte(' ');
|
||||
try writeRule(out, w.value_w);
|
||||
try out.writeByte(' ');
|
||||
try writeRule(out, w.gainloss_w);
|
||||
try out.writeByte(' ');
|
||||
try writeRule(out, w.weight_w);
|
||||
try out.writeByte(' ');
|
||||
try writeRule(out, w.date_w);
|
||||
try out.writeByte(' ');
|
||||
try writeRule(out, PositionsLayout.account_w);
|
||||
try out.writeByte('\n');
|
||||
}
|
||||
|
||||
/// Write the CLI TOTAL-row sum rule, spanning Symbol .. Weight
|
||||
/// (7 column rules).
|
||||
pub fn writeTotalSeparator(out: *std.Io.Writer, w: PositionsWidths) std.Io.Writer.Error!void {
|
||||
try out.writeAll(" ");
|
||||
try writeRule(out, w.symbol_w);
|
||||
try out.writeByte(' ');
|
||||
try writeRule(out, w.shares_w);
|
||||
try out.writeByte(' ');
|
||||
try writeRule(out, w.price_w);
|
||||
try out.writeByte(' ');
|
||||
try writeRule(out, w.price_w);
|
||||
try out.writeByte(' ');
|
||||
try writeRule(out, w.value_w);
|
||||
try out.writeByte(' ');
|
||||
try writeRule(out, w.gainloss_w);
|
||||
try out.writeByte(' ');
|
||||
try writeRule(out, w.weight_w);
|
||||
try out.writeByte('\n');
|
||||
}
|
||||
|
||||
/// Write a dashed rule `width` columns wide.
|
||||
fn writeRule(out: *std.Io.Writer, width: usize) std.Io.Writer.Error!void {
|
||||
try out.splatByteAll('-', width);
|
||||
}
|
||||
|
||||
// ── Options ───────────────────────────────────────────────────
|
||||
|
||||
/// Column layout for the Options section.
|
||||
|
|
@ -420,3 +591,141 @@ test "CDs.init: all matured yields empty active slice" {
|
|||
try testing.expectEqual(@as(usize, 2), cds.expiredItems().len);
|
||||
try testing.expectEqual(@as(usize, 0), cds.activeItems().len);
|
||||
}
|
||||
|
||||
// ── computeWidths ─────────────────────────────────────────────
|
||||
|
||||
/// Build a minimal Allocation for width tests. `cost_basis` is derived
|
||||
/// so `unrealized_gain_loss` is consistent (not that computeWidths
|
||||
/// reads cost_basis, but it keeps the fixture honest).
|
||||
fn mkAlloc(symbol: []const u8, shares: f64, avg_cost: f64, current_price: f64, market_value: f64, gl: f64) Allocation {
|
||||
return .{
|
||||
.symbol = symbol,
|
||||
.display_symbol = symbol,
|
||||
.shares = shares,
|
||||
.avg_cost = avg_cost,
|
||||
.current_price = current_price,
|
||||
.market_value = market_value,
|
||||
.cost_basis = market_value - gl,
|
||||
.weight = 1.0,
|
||||
.unrealized_gain_loss = gl,
|
||||
.unrealized_return = 0,
|
||||
};
|
||||
}
|
||||
|
||||
test "computeWidths: empty portfolio sits at the header-label floors" {
|
||||
const w = computeWidths(&.{}, &.{}, 0, 0, &.{}, null);
|
||||
try testing.expectEqual(@as(usize, PositionsLayout.min_symbol_w), w.symbol_w);
|
||||
try testing.expectEqual(@as(usize, PositionsLayout.min_shares_w), w.shares_w);
|
||||
try testing.expectEqual(@as(usize, PositionsLayout.min_price_w), w.price_w);
|
||||
try testing.expectEqual(@as(usize, PositionsLayout.min_value_w), w.value_w);
|
||||
try testing.expectEqual(@as(usize, PositionsLayout.min_gainloss_w), w.gainloss_w);
|
||||
// Fixed columns are mirrored in unchanged.
|
||||
try testing.expectEqual(@as(usize, PositionsLayout.weight_w), w.weight_w);
|
||||
try testing.expectEqual(@as(usize, PositionsLayout.date_w), w.date_w);
|
||||
}
|
||||
|
||||
test "computeWidths: a small portfolio stays at the floors (tightens, no waste)" {
|
||||
// IBM(3) / 10.0(4) / $150.00(7) / $155.00(7) / $1,550.00(9) / +$50.00(7)
|
||||
// are all narrower than their header labels, so every column stays
|
||||
// at its minimum. This is the "don't waste space" half of
|
||||
// fit-to-content.
|
||||
const allocs = [_]Allocation{mkAlloc("IBM", 10, 150, 155, 1550, 50)};
|
||||
const w = computeWidths(&allocs, &.{}, 1550, 50, &.{}, null);
|
||||
try testing.expectEqual(@as(usize, 6), w.symbol_w); // "Symbol"
|
||||
try testing.expectEqual(@as(usize, 6), w.shares_w); // "Shares"
|
||||
try testing.expectEqual(@as(usize, 8), w.price_w); // "Avg Cost"
|
||||
try testing.expectEqual(@as(usize, 12), w.value_w); // "Market Value"
|
||||
try testing.expectEqual(@as(usize, 9), w.gainloss_w); // "Gain/Loss"
|
||||
}
|
||||
|
||||
test "computeWidths: large crypto-scale values grow each column" {
|
||||
// DOGE-USD(8) / 10000.0(7) / $42,000.00(10) / $420,000,000.00(15)
|
||||
// / +$5,000,000.00(14, sign + $5,000,000.00).
|
||||
const allocs = [_]Allocation{mkAlloc("DOGE-USD", 10000, 41500, 42000, 420000000, 5000000)};
|
||||
const w = computeWidths(&allocs, &.{}, 420000000, 5000000, &.{}, null);
|
||||
try testing.expectEqual(@as(usize, 8), w.symbol_w);
|
||||
try testing.expectEqual(@as(usize, 7), w.shares_w);
|
||||
try testing.expectEqual(@as(usize, 10), w.price_w); // max("$41,500.00","$42,000.00") = 10
|
||||
try testing.expectEqual(@as(usize, 15), w.value_w);
|
||||
try testing.expectEqual(@as(usize, 14), w.gainloss_w);
|
||||
}
|
||||
|
||||
test "computeWidths: a single lot's gain/loss can exceed the netted position" {
|
||||
// Position nets to $0 gain/loss, but two opposite-sign lots each
|
||||
// swing $9,900 ("+$9,900.00" = 10 cols). Without scanning lots the
|
||||
// Gain/Loss column would size to the $0 net (the 9-col floor) and a
|
||||
// lot row would overflow.
|
||||
const allocs = [_]Allocation{mkAlloc("ABC", 200, 100, 100, 20000, 0)};
|
||||
const lots = [_]Lot{
|
||||
.{ .symbol = "ABC", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0 },
|
||||
.{ .symbol = "ABC", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 199.0 },
|
||||
};
|
||||
// Allocations only: stays at the 9-col floor.
|
||||
const no_lots = computeWidths(&allocs, &.{}, 20000, 0, &.{}, null);
|
||||
try testing.expectEqual(@as(usize, 9), no_lots.gainloss_w);
|
||||
// With lots: grows to fit the per-lot swing.
|
||||
const w = computeWidths(&allocs, &lots, 20000, 0, &.{}, null);
|
||||
try testing.expectEqual(@as(usize, 10), w.gainloss_w);
|
||||
}
|
||||
|
||||
test "computeWidths: non-stock lots skipped, close_price honored, orphan lot tolerated" {
|
||||
const allocs = [_]Allocation{mkAlloc("ABC", 100, 50, 60, 6000, 1000)};
|
||||
const lots = [_]Lot{
|
||||
// Non-stock lot is skipped: its huge share count must NOT widen
|
||||
// the Shares column.
|
||||
.{ .symbol = "CASHX", .shares = 999999, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash },
|
||||
// Closed stock lot: market value / gain-loss use close_price
|
||||
// (1234), not the allocation's current price (60). gl =
|
||||
// 100 * (1234 - 10) = 122400 -> "+$122,400.00" = 12 cols.
|
||||
.{ .symbol = "ABC", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .close_price = 1234.0 },
|
||||
// Stock lot whose symbol has no matching allocation and no
|
||||
// close_price: currentPriceFor falls back to 0 (exercises the
|
||||
// not-found path), contributing nothing.
|
||||
.{ .symbol = "ORPHAN", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 7.0 },
|
||||
};
|
||||
const w = computeWidths(&allocs, &lots, 6000, 1000, &.{}, null);
|
||||
// Non-stock lot skipped -> Shares stays at the 6-col floor (the
|
||||
// 999999.0 = 8-col share count was ignored).
|
||||
try testing.expectEqual(@as(usize, 6), w.shares_w);
|
||||
// close_price honored -> Gain/Loss grows to the closed lot's 12-col
|
||||
// swing. (Had currentPriceFor been used instead, the swing would be
|
||||
// 100*(60-10) = 5000 -> 10 cols, so 12 proves close_price won.)
|
||||
try testing.expectEqual(@as(usize, 12), w.gainloss_w);
|
||||
}
|
||||
|
||||
test "computeWidths: watchlist symbols and prices widen Symbol / Price" {
|
||||
var wp = std.StringHashMap(f64).init(testing.allocator);
|
||||
defer wp.deinit();
|
||||
try wp.put("VERYLONGSYM", 1234.56); // "$1,234.56" = 9 cols
|
||||
// "NOPRICE" has no map entry, so it widens Symbol but not Price
|
||||
// (exercises the price-absent branch).
|
||||
const watch = [_][]const u8{ "VERYLONGSYM", "NOPRICE" };
|
||||
const w = computeWidths(&.{}, &.{}, 0, 0, &watch, wp);
|
||||
try testing.expectEqual(@as(usize, 11), w.symbol_w);
|
||||
try testing.expectEqual(@as(usize, 9), w.price_w);
|
||||
}
|
||||
|
||||
test "writeHeader / writeSeparator render the same column count as the widths" {
|
||||
var buf: [256]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
const widths: PositionsWidths = .{ .symbol_w = 8, .shares_w = 7, .price_w = 10, .value_w = 15, .gainloss_w = 14 };
|
||||
try writeHeader(&w, widths);
|
||||
const header = w.buffered();
|
||||
try testing.expect(std.mem.indexOf(u8, header, "Symbol") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, header, "Market Value") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, header, "Account") != null);
|
||||
try testing.expect(std.mem.endsWith(u8, header, "\n"));
|
||||
|
||||
var sbuf: [256]u8 = undefined;
|
||||
var sw: std.Io.Writer = .fixed(&sbuf);
|
||||
try writeSeparator(&sw, widths);
|
||||
const sep = sw.buffered();
|
||||
// The separator's per-column rules match the header columns. Its one
|
||||
// intentional difference is the trailing Account rule, which is a
|
||||
// fixed decorative width (account_w) rather than the natural-width
|
||||
// "Account" label, so the separator is exactly that much longer.
|
||||
try testing.expectEqual(
|
||||
fmt.displayCols(header[0 .. header.len - 1]) - "Account".len + PositionsLayout.account_w,
|
||||
fmt.displayCols(sep[0 .. sep.len - 1]),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue