migrate portfolio tab to new framework

This commit is contained in:
Emil Lerch 2026-05-15 08:06:34 -07:00
parent afe9eacf1d
commit 492774c04e
2 changed files with 513 additions and 343 deletions

View file

@ -289,6 +289,7 @@ pub const TabStates = struct {
options: options_tab.State = .{},
history: history_tab.State = .{},
projections: projections_tab.State = .{},
portfolio: portfolio_tab.State = .{},
};
/// Comptime registry of all tab modules conforming to the
@ -327,6 +328,7 @@ const tab_modules = .{
.options = options_tab,
.history = history_tab,
.projections = projections_tab,
.portfolio = portfolio_tab,
};
comptime {
@ -550,31 +552,17 @@ pub const App = struct {
input_buf: [16]u8 = undefined,
input_len: usize = 0,
// Portfolio navigation
cursor: usize = 0, // selected row in portfolio view
expanded: [64]bool = @splat(false), // which positions are expanded
cash_expanded: bool = false, // whether cash section is expanded to show per-account
illiquid_expanded: bool = false, // whether illiquid section is expanded to show per-asset
portfolio_rows: std.ArrayList(PortfolioRow) = .empty,
prepared_options: ?views.Options = null,
prepared_cds: ?views.CDs = null,
portfolio_header_lines: usize = 0, // number of styled lines before data rows
portfolio_line_to_row: [256]usize = @splat(0), // maps styled line index -> portfolio_rows index
portfolio_line_count: usize = 0, // total styled lines in portfolio view
portfolio_sort_field: PortfolioSortField = .symbol, // current sort column
portfolio_sort_dir: SortDirection = .asc, // current sort direction
prefetched_prices: ?std.StringHashMap(f64) = null, // prices loaded before TUI starts (with stderr progress)
// Portfolio tab state lives in `self.states.portfolio` (see TabStates).
// Account filter state
account_filter: ?[]const u8 = null, // active account filter (owned copy; null = all accounts)
filtered_positions: ?[]zfin.Position = null, // positions for filtered account (from positionsForAccount)
account_list: std.ArrayList([]const u8) = .empty, // distinct accounts from portfolio lots (ordered by accounts.srf)
account_numbers: std.ArrayList(?[]const u8) = .empty, // account_number from accounts.srf (parallel to account_list)
account_shortcut_keys: std.ArrayList(u8) = .empty, // auto-assigned shortcut key per account (parallel to account_list)
// Account picker / search modal state. The portfolio tab opens
// the picker via the `account_filter` action, but the picker
// itself is a global UI mode (mode = .account_picker) that
// operates on portfolio state via `self.states.portfolio`.
// Search-mode is mode = .account_search.
account_picker_cursor: usize = 0, // cursor position in picker (0 = "All accounts")
account_search_buf: [64]u8 = undefined,
account_search_len: usize = 0,
account_search_matches: std.ArrayList(usize) = .empty, // indices into account_list matching search
account_search_matches: std.ArrayList(usize) = .empty, // indices into states.portfolio.account_list matching search
account_search_cursor: usize = 0, // cursor within search_matches
// History tab state lives in `self.states.history` (see TabStates).
@ -634,7 +622,7 @@ pub const App = struct {
fn handleMouse(self: *App, ctx: *vaxis.vxfw.EventContext, mouse: vaxis.Mouse) void {
// Account picker mouse handling
if (self.mode == .account_picker) {
const total_items = self.account_list.items.len + 1;
const total_items = self.states.portfolio.account_list.items.len + 1;
switch (mouse.button) {
.wheel_up => {
if (self.shouldDebounceWheel()) return;
@ -695,56 +683,6 @@ pub const App = struct {
col += lbl_len;
}
}
// Portfolio tab: click header to sort, click row to expand/collapse
// Portfolio tab: click header to sort, click row to expand/collapse
if (self.active_tab == .portfolio and mouse.row > 0) {
const content_row = @as(usize, @intCast(mouse.row)) + self.scroll_offset;
// Click on column header row -> sort by that column
if (self.portfolio_header_lines > 0 and content_row == self.portfolio_header_lines - 1) {
const col = @as(usize, @intCast(mouse.col));
const new_field: ?PortfolioSortField =
if (col < portfolio_tab.col_end_symbol)
.symbol
else if (col < portfolio_tab.col_end_shares)
.shares
else if (col < portfolio_tab.col_end_avg_cost)
.avg_cost
else if (col < portfolio_tab.col_end_price)
.price
else if (col < portfolio_tab.col_end_market_value)
.market_value
else if (col < portfolio_tab.col_end_gain_loss)
.gain_loss
else if (col < portfolio_tab.col_end_weight)
.weight
else if (col < portfolio_tab.col_end_date)
null // Date (not sortable)
else
.account;
if (new_field) |nf| {
if (nf == self.portfolio_sort_field) {
self.portfolio_sort_dir = self.portfolio_sort_dir.flip();
} else {
self.portfolio_sort_field = nf;
self.portfolio_sort_dir = if (nf == .symbol or nf == .account) .asc else .desc;
}
self.sortPortfolioAllocations();
self.rebuildPortfolioRows();
return ctx.consumeAndRedraw();
}
}
if (content_row >= self.portfolio_header_lines and self.portfolio_rows.items.len > 0) {
const line_idx = content_row - self.portfolio_header_lines;
if (line_idx < self.portfolio_line_count and line_idx < self.portfolio_line_to_row.len) {
const row_idx = self.portfolio_line_to_row[line_idx];
if (row_idx < self.portfolio_rows.items.len) {
self.cursor = row_idx;
self.toggleExpand();
return ctx.consumeAndRedraw();
}
}
}
}
// Framework dispatch: ask the active tab's `handleMouse`
// (when defined) if it wants to consume this click.
//
@ -1024,7 +962,7 @@ pub const App = struct {
/// Handles keypresses in account_picker mode.
fn handleAccountPickerKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void {
const total_items = self.account_list.items.len + 1; // +1 for "All accounts"
const total_items = self.states.portfolio.account_list.items.len + 1; // +1 for "All accounts"
if (key.codepoint == vaxis.Key.escape or key.codepoint == 'q') {
self.mode = .normal;
@ -1054,7 +992,7 @@ pub const App = struct {
// Check shortcut keys for instant selection
if (key.codepoint < std.math.maxInt(u7) and key.matches(key.codepoint, .{})) {
const ch: u8 = @intCast(key.codepoint);
for (self.account_shortcut_keys.items, 0..) |shortcut, i| {
for (self.states.portfolio.account_shortcut_keys.items, 0..) |shortcut, i| {
if (shortcut == ch) {
self.account_picker_cursor = i + 1; // +1 for "All accounts" at 0
self.applyAccountPickerSelection();
@ -1157,11 +1095,11 @@ pub const App = struct {
for (query, 0..) |c, i| lower_query[i] = std.ascii.toLower(c);
const lq = lower_query[0..query.len];
for (self.account_list.items, 0..) |acct, i| {
for (self.states.portfolio.account_list.items, 0..) |acct, i| {
if (containsLower(acct, lq)) {
self.account_search_matches.append(self.allocator, i) catch continue;
} else if (i < self.account_numbers.items.len) {
if (self.account_numbers.items[i]) |num| {
} else if (i < self.states.portfolio.account_numbers.items.len) {
if (self.states.portfolio.account_numbers.items[i]) |num| {
if (containsLower(num, lq)) {
self.account_search_matches.append(self.allocator, i) catch continue;
}
@ -1201,16 +1139,16 @@ pub const App = struct {
self.setAccountFilter(null);
} else {
const idx = self.account_picker_cursor - 1;
if (idx < self.account_list.items.len) {
self.setAccountFilter(self.account_list.items[idx]);
if (idx < self.states.portfolio.account_list.items.len) {
self.setAccountFilter(self.states.portfolio.account_list.items[idx]);
}
}
self.mode = .normal;
self.cursor = 0;
self.states.portfolio.cursor = 0;
self.scroll_offset = 0;
portfolio_tab.rebuildPortfolioRows(self);
portfolio_tab.rebuildPortfolioRows(&self.states.portfolio, self);
if (self.account_filter) |af| {
if (self.states.portfolio.account_filter) |af| {
var tmp_buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&tmp_buf, "Filtered: {s}", .{af}) catch "Filtered";
self.setStatus(msg);
@ -1228,17 +1166,17 @@ pub const App = struct {
/// Set or clear the account filter. Owns the string via allocator.
pub fn setAccountFilter(self: *App, name: ?[]const u8) void {
if (self.account_filter) |old| self.allocator.free(old);
if (self.filtered_positions) |fp| self.allocator.free(fp);
self.filtered_positions = null;
if (self.states.portfolio.account_filter) |old| self.allocator.free(old);
if (self.states.portfolio.filtered_positions) |fp| self.allocator.free(fp);
self.states.portfolio.filtered_positions = null;
if (name) |n| {
self.account_filter = self.allocator.dupe(u8, n) catch null;
self.states.portfolio.account_filter = self.allocator.dupe(u8, n) catch null;
if (self.portfolio.file) |pf| {
self.filtered_positions = pf.positionsForAccount(self.today, self.allocator, n) catch null;
self.states.portfolio.filtered_positions = pf.positionsForAccount(self.today, self.allocator, n) catch null;
}
} else {
self.account_filter = null;
self.states.portfolio.account_filter = null;
}
}
@ -1263,11 +1201,11 @@ pub const App = struct {
// Escape: clear account filter on portfolio tab, clear as-of
// on projections tab, no-op otherwise.
if (key.codepoint == vaxis.Key.escape) {
if (self.active_tab == .portfolio and self.account_filter != null) {
if (self.active_tab == .portfolio and self.states.portfolio.account_filter != null) {
self.setAccountFilter(null);
self.cursor = 0;
self.states.portfolio.cursor = 0;
self.scroll_offset = 0;
portfolio_tab.rebuildPortfolioRows(self);
portfolio_tab.rebuildPortfolioRows(&self.states.portfolio, self);
self.setStatus("Filter cleared: showing all accounts");
return ctx.consumeAndRedraw();
}
@ -1294,13 +1232,8 @@ pub const App = struct {
},
.select_symbol => {
// 's' selects the current portfolio row's symbol as the active symbol
if (self.active_tab == .portfolio and self.portfolio_rows.items.len > 0 and self.cursor < self.portfolio_rows.items.len) {
const row = self.portfolio_rows.items[self.cursor];
self.setActiveSymbol(row.symbol);
// Format into a separate buffer to avoid aliasing with status_msg
var tmp_buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&tmp_buf, "Active: {s}", .{row.symbol}) catch "Active";
self.setStatus(msg);
if (self.active_tab == .portfolio) {
portfolio_tab.tab.handleAction(&self.states.portfolio, self, portfolio_tab.Action.select_symbol);
return ctx.consumeAndRedraw();
}
},
@ -1344,7 +1277,7 @@ pub const App = struct {
},
.expand_collapse => {
if (self.active_tab == .portfolio) {
self.toggleExpand();
portfolio_tab.tab.handleAction(&self.states.portfolio, self, portfolio_tab.Action.expand_collapse);
return ctx.consumeAndRedraw();
} else if (self.active_tab == .options) {
options_tab.tab.handleAction(&self.states.options, self, options_tab.Action.expand_collapse);
@ -1377,14 +1310,11 @@ pub const App = struct {
},
.scroll_top => {
self.scroll_offset = 0;
if (self.active_tab == .portfolio) self.cursor = 0;
self.dispatchVoid("onScroll", .{tab_framework.ScrollEdge.top});
return ctx.consumeAndRedraw();
},
.scroll_bottom => {
self.scroll_offset = std.math.maxInt(usize) / 2; // clamped during draw...divide by 2 to avoid overflow if arithmetic is done
if (self.active_tab == .portfolio and self.portfolio_rows.items.len > 0)
self.cursor = self.portfolio_rows.items.len - 1;
self.dispatchVoid("onScroll", .{tab_framework.ScrollEdge.bottom});
return ctx.consumeAndRedraw();
},
@ -1446,23 +1376,13 @@ pub const App = struct {
},
.sort_col_next => {
if (self.active_tab == .portfolio) {
if (self.portfolio_sort_field.next()) |new_field| {
self.portfolio_sort_field = new_field;
self.portfolio_sort_dir = if (new_field == .symbol or new_field == .account) .asc else .desc;
self.sortPortfolioAllocations();
self.rebuildPortfolioRows();
}
portfolio_tab.tab.handleAction(&self.states.portfolio, self, portfolio_tab.Action.sort_col_next);
return ctx.consumeAndRedraw();
}
},
.sort_col_prev => {
if (self.active_tab == .portfolio) {
if (self.portfolio_sort_field.prev()) |new_field| {
self.portfolio_sort_field = new_field;
self.portfolio_sort_dir = if (new_field == .symbol or new_field == .account) .asc else .desc;
self.sortPortfolioAllocations();
self.rebuildPortfolioRows();
}
portfolio_tab.tab.handleAction(&self.states.portfolio, self, portfolio_tab.Action.sort_col_prev);
return ctx.consumeAndRedraw();
}
},
@ -1477,9 +1397,7 @@ pub const App = struct {
// select_symbol). The action name stays `sort_reverse`
// because portfolio was the first consumer.
if (self.active_tab == .portfolio) {
self.portfolio_sort_dir = self.portfolio_sort_dir.flip();
self.sortPortfolioAllocations();
self.rebuildPortfolioRows();
portfolio_tab.tab.handleAction(&self.states.portfolio, self, portfolio_tab.Action.sort_reverse);
return ctx.consumeAndRedraw();
}
if (self.active_tab == .projections) {
@ -1488,18 +1406,8 @@ pub const App = struct {
}
},
.account_filter => {
if (self.active_tab == .portfolio and self.portfolio.file != null) {
self.mode = .account_picker;
// Position cursor on the currently-active filter (or 0 for "All")
self.account_picker_cursor = 0;
if (self.account_filter) |af| {
for (self.account_list.items, 0..) |acct, ai| {
if (std.mem.eql(u8, acct, af)) {
self.account_picker_cursor = ai + 1; // +1 because 0 = "All accounts"
break;
}
}
}
if (self.active_tab == .portfolio) {
portfolio_tab.tab.handleAction(&self.states.portfolio, self, portfolio_tab.Action.open_account_picker);
return ctx.consumeAndRedraw();
}
},
@ -1576,17 +1484,8 @@ pub const App = struct {
/// For other tabs (or cursor-bearing tabs with empty rows),
/// adjusts scroll_offset by |n|.
fn moveBy(self: *App, n: isize) void {
// Unmigrated cursor-bearing tab (portfolio).
// Its cursor state still lives on App; once migrated, this
// branch goes into an onCursorMove hook like options/history.
if (self.active_tab == .portfolio) {
if (self.shouldDebounceWheel()) return;
stepCursor(&self.cursor, self.portfolio_rows.items.len, n);
self.ensureCursorVisible();
return;
}
// Migrated cursor-bearing tabs (options, history). The
// hook returns false when it has no rows, so we fall
// Migrated cursor-bearing tabs (portfolio, options, history).
// The hook returns false when it has no rows, so we fall
// through to scroll. Debounce applies to the cursor-move
// path only preserving legacy behavior where wheel
// events on non-cursor views scroll without debounce.
@ -1604,56 +1503,6 @@ pub const App = struct {
}
}
fn stepCursor(cursor: *usize, row_count: usize, direction: isize) void {
if (direction > 0) {
if (row_count > 0 and cursor.* < row_count - 1)
cursor.* += 1;
} else {
if (cursor.* > 0) cursor.* -= 1;
}
}
fn ensureCursorVisible(self: *App) void {
const cursor_row = self.cursor + self.portfolio_header_lines;
if (cursor_row < self.scroll_offset) {
self.scroll_offset = cursor_row;
}
const vis: usize = self.visible_height;
if (cursor_row >= self.scroll_offset + vis) {
self.scroll_offset = cursor_row - vis + 1;
}
}
fn toggleExpand(self: *App) void {
if (self.portfolio_rows.items.len == 0) return;
if (self.cursor >= self.portfolio_rows.items.len) return;
const row = self.portfolio_rows.items[self.cursor];
switch (row.kind) {
.position => {
// Single-lot positions don't expand
if (row.lot_count <= 1) return;
if (row.pos_idx < self.expanded.len) {
self.expanded[row.pos_idx] = !self.expanded[row.pos_idx];
self.rebuildPortfolioRows();
}
},
.lot, .option_row, .cd_row, .cash_row, .illiquid_row, .section_header, .drip_summary => {},
.cash_total => {
self.cash_expanded = !self.cash_expanded;
self.rebuildPortfolioRows();
},
.illiquid_total => {
self.illiquid_expanded = !self.illiquid_expanded;
self.rebuildPortfolioRows();
},
.watchlist => {
self.setActiveSymbol(row.symbol);
self.active_tab = .quote;
self.loadTabData();
},
}
}
pub fn setActiveSymbol(self: *App, sym: []const u8) void {
const len = @min(sym.len, self.symbol_buf.len);
@memcpy(self.symbol_buf[0..len], sym[0..len]);
@ -1743,10 +1592,10 @@ pub const App = struct {
}
}
fn loadTabData(self: *App) void {
pub fn loadTabData(self: *App) void {
switch (self.active_tab) {
.portfolio => {
if (!self.portfolio.loaded) self.loadPortfolioData();
portfolio_tab.tab.activate(&self.states.portfolio, self) catch {};
},
.quote, .performance => {
if (self.symbol.len == 0) return;
@ -1773,15 +1622,7 @@ pub const App = struct {
}
pub fn loadPortfolioData(self: *App) void {
portfolio_tab.loadPortfolioData(self);
}
fn sortPortfolioAllocations(self: *App) void {
portfolio_tab.sortPortfolioAllocations(self);
}
fn rebuildPortfolioRows(self: *App) void {
portfolio_tab.rebuildPortfolioRows(self);
portfolio_tab.loadPortfolioData(&self.states.portfolio, self);
}
pub fn setStatus(self: *App, msg: []const u8) void {
@ -1800,25 +1641,12 @@ pub const App = struct {
self.portfolio.summary = null;
}
pub fn freePreparedSections(self: *App) void {
if (self.prepared_options) |*opts| opts.deinit();
self.prepared_options = null;
if (self.prepared_cds) |*cds| cds.deinit();
self.prepared_cds = null;
}
fn deinitData(self: *App) void {
self.symbol_data.deinit(self.allocator);
earnings_tab.tab.deinit(&self.states.earnings, self);
options_tab.tab.deinit(&self.states.options, self);
self.freePreparedSections();
self.portfolio_rows.deinit(self.allocator);
self.account_list.deinit(self.allocator);
self.account_numbers.deinit(self.allocator);
self.account_shortcut_keys.deinit(self.allocator);
portfolio_tab.tab.deinit(&self.states.portfolio, self);
self.account_search_matches.deinit(self.allocator);
if (self.account_filter) |af| self.allocator.free(af);
if (self.filtered_positions) |fp| self.allocator.free(fp);
analysis_tab.tab.deinit(&self.states.analysis, self);
self.portfolio.deinit(self.allocator);
history_tab.tab.deinit(&self.states.history, self);
@ -1827,7 +1655,7 @@ pub const App = struct {
}
fn reloadPortfolioFile(self: *App) void {
portfolio_tab.reloadPortfolioFile(self);
portfolio_tab.reloadPortfolioFile(&self.states.portfolio, self);
}
// Drawing
@ -1910,9 +1738,9 @@ pub const App = struct {
fn isSymbolSelected(self: *App) bool {
// Symbol is "selected" if it matches a portfolio/watchlist row the user explicitly selected with 's'
if (self.active_tab != .portfolio) return false;
if (self.portfolio_rows.items.len == 0) return false;
if (self.cursor >= self.portfolio_rows.items.len) return false;
return std.mem.eql(u8, self.portfolio_rows.items[self.cursor].symbol, self.symbol);
if (self.states.portfolio.rows.items.len == 0) return false;
if (self.states.portfolio.cursor >= self.states.portfolio.rows.items.len) return false;
return std.mem.eql(u8, self.states.portfolio.rows.items[self.states.portfolio.cursor].symbol, self.symbol);
}
fn drawContent(self: *App, ctx: vaxis.vxfw.DrawContext, width: u16, height: u16) !vaxis.vxfw.Surface {
@ -1925,7 +1753,7 @@ pub const App = struct {
if (self.mode == .help) {
try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildHelpStyledLines(ctx.arena));
} else if (self.mode == .account_picker or self.mode == .account_search) {
try portfolio_tab.drawAccountPicker(self, ctx.arena, buf, width, height);
try portfolio_tab.drawAccountPicker(&self.states.portfolio, self, ctx.arena, buf, width, height);
} else {
switch (self.active_tab) {
.portfolio => try self.drawPortfolioContent(ctx.arena, buf, width, height),
@ -2049,8 +1877,8 @@ pub const App = struct {
const status_style = t.statusStyle();
@memset(buf, .{ .char = .{ .grapheme = " " }, .style = status_style });
// Show account filter indicator when active, appended to status message
if (self.account_filter != null and self.active_tab == .portfolio) {
const af = self.account_filter.?;
if (self.states.portfolio.account_filter != null and self.active_tab == .portfolio) {
const af = self.states.portfolio.account_filter.?;
const msg = self.getStatus();
const filter_text = std.fmt.allocPrint(ctx.arena, "{s} [Account: {s}]", .{ msg, af }) catch msg;
for (0..@min(filter_text.len, width)) |i| {
@ -2070,7 +1898,7 @@ pub const App = struct {
// Portfolio content
fn drawPortfolioContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
return portfolio_tab.drawContent(self, arena, buf, width, height);
return portfolio_tab.drawContent(&self.states.portfolio, self, arena, buf, width, height);
}
// Options content (with cursor/scroll)
@ -2516,7 +2344,7 @@ pub fn run(
false, // force_refresh
true, // color
);
app_inst.prefetched_prices = load_result.prices;
app_inst.states.portfolio.prefetched_prices = load_result.prices;
}
// Eagerly compute PortfolioData so the history-tab's live
@ -2525,7 +2353,7 @@ pub fn run(
// first. Cheap (pure compute + cache reads) once prices are
// already in hand.
if (app_inst.portfolio.file != null) {
portfolio_tab.loadPortfolioData(app_inst);
portfolio_tab.loadPortfolioData(&app_inst.states.portfolio, app_inst);
}
}

View file

@ -9,11 +9,13 @@ const theme = @import("theme.zig");
const tui = @import("../tui.zig");
const projections_tab = @import("projections_tab.zig");
const analysis_tab = @import("analysis_tab.zig");
const framework = @import("tab_framework.zig");
const App = tui.App;
const StyledLine = tui.StyledLine;
const PortfolioRow = tui.PortfolioRow;
const PortfolioSortField = tui.PortfolioSortField;
const SortDirection = tui.SortDirection;
const colLabel = tui.colLabel;
const glyph = tui.glyph;
@ -23,6 +25,343 @@ const glyph = tui.glyph;
const prefix_cols: usize = 4;
const sw: usize = fmt.sym_col_width;
// Tab-local action enum
//
// Portfolio tab keybinds (today routed through legacy global
// `keybinds.Action` variants these tab-local declarations
// become authoritative when scoped keymaps land):
// - Enter : expand/collapse position at cursor
// - `s` / space : select cursor row's symbol as active
// - `S` / `;` : sort columns (next/prev)
// - `o` : reverse sort direction
// - `a` : open account picker
pub const Action = enum {
/// Expand/collapse the position at the cursor row.
expand_collapse,
/// Move the cursor to the next sortable column.
sort_col_next,
/// Move the cursor to the previous sortable column.
sort_col_prev,
/// Flip the current sort direction (asc desc).
sort_reverse,
/// Open the account picker modal (mode = .account_picker).
/// No-op if no portfolio is loaded.
open_account_picker,
/// Select the cursor row's symbol as the currently-active
/// symbol for the per-symbol tabs (quote/perf/options/etc.).
select_symbol,
};
// Tab-private state
pub const State = struct {
/// Selected row in the portfolio view.
cursor: usize = 0,
/// Per-position expansion flags. Indices align with
/// `app.portfolio.summary.allocations`. Fixed-size; positions
/// beyond index 64 are non-expandable in the UI.
expanded: [64]bool = @splat(false),
/// Whether the cash section is expanded to show per-account
/// balances.
cash_expanded: bool = false,
/// Whether the illiquid section is expanded to show
/// per-asset rows.
illiquid_expanded: bool = false,
/// Flat list of styled rows for the current view, rebuilt by
/// `rebuildPortfolioRows` whenever sort / filter / expansion
/// changes. Owned by State.
rows: std.ArrayList(PortfolioRow) = .empty,
/// Pre-computed Options section, when the portfolio holds
/// option positions. Owned by State.
prepared_options: ?views.Options = null,
/// Pre-computed CDs section, when the portfolio holds CDs.
/// Owned by State.
prepared_cds: ?views.CDs = null,
/// Number of styled lines before the first data row (header,
/// totals, etc.). Used by mouse hit-tests and cursor-visibility
/// math.
header_lines: usize = 0,
/// Maps styled-line index row index in `rows`. Sized to a
/// fixed cap; `line_count` is the live extent.
line_to_row: [256]usize = @splat(0),
/// Total styled lines in the portfolio view (sum of header
/// + per-row lines).
line_count: usize = 0,
/// Current sort column.
sort_field: PortfolioSortField = .symbol,
/// Current sort direction (default: asc for symbol/account,
/// desc for numeric columns).
sort_dir: SortDirection = .asc,
/// Active account filter (owned copy; null = all accounts).
account_filter: ?[]const u8 = null,
/// Cached positions for the active account filter, computed
/// on filter change so subsequent renders don't re-iterate
/// the lots list.
filtered_positions: ?[]zfin.Position = null,
/// Distinct accounts from the portfolio's lots, ordered as
/// they appear in `accounts.srf` (or first-seen if no
/// accounts.srf).
account_list: std.ArrayList([]const u8) = .empty,
/// Account number from `accounts.srf` (parallel to
/// `account_list`). Null entries mean "no account number".
account_numbers: std.ArrayList(?[]const u8) = .empty,
/// Auto-assigned shortcut-key per account, parallel to
/// `account_list`. Used by the account picker modal.
account_shortcut_keys: std.ArrayList(u8) = .empty,
/// Prefetched prices populated before the TUI started (with
/// stderr progress). Consumed by the first `loadPortfolioData`
/// call to skip redundant network round-trips. Owned by State;
/// freed after first consumption.
prefetched_prices: ?std.StringHashMap(f64) = null,
};
// Tab framework contract
pub const tab = struct {
pub const ActionT = Action;
pub const StateT = State;
pub const default_bindings: []const framework.TabBinding(Action) = &.{
// These are dead until scoped keymaps land the global
// keymap matches first. Declared per-tab so the help
// overlay and future scoped-dispatch can find them.
.{ .action = .expand_collapse, .key = .{ .codepoint = vaxis.Key.enter } },
.{ .action = .sort_col_next, .key = .{ .codepoint = 'S' } },
.{ .action = .sort_col_prev, .key = .{ .codepoint = ';' } },
.{ .action = .sort_reverse, .key = .{ .codepoint = 'o' } },
.{ .action = .open_account_picker, .key = .{ .codepoint = 'a' } },
.{ .action = .select_symbol, .key = .{ .codepoint = 's' } },
.{ .action = .select_symbol, .key = .{ .codepoint = vaxis.Key.space } },
};
pub const action_labels = std.enums.EnumArray(Action, []const u8).init(.{
.expand_collapse = "Expand/collapse position",
.sort_col_next = "Sort: next column",
.sort_col_prev = "Sort: previous column",
.sort_reverse = "Sort: reverse direction",
.open_account_picker = "Filter by account",
.select_symbol = "Select symbol",
});
pub const status_hints: []const Action = &.{
.sort_col_next,
.sort_reverse,
.open_account_picker,
};
pub fn init(state: *State, app: *App) !void {
_ = app;
state.* = .{};
}
pub fn deinit(state: *State, app: *App) void {
state.rows.deinit(app.allocator);
if (state.prepared_options) |*opts| opts.deinit();
if (state.prepared_cds) |*cds| cds.deinit();
if (state.account_filter) |af| app.allocator.free(af);
if (state.filtered_positions) |fp| app.allocator.free(fp);
state.account_list.deinit(app.allocator);
state.account_numbers.deinit(app.allocator);
state.account_shortcut_keys.deinit(app.allocator);
state.* = .{};
}
pub fn activate(state: *State, app: *App) !void {
if (app.portfolio.loaded) return;
loadPortfolioData(state, app);
}
pub const deactivate = framework.noopDeactivate(State);
pub fn reload(state: *State, app: *App) !void {
reloadPortfolioFile(state, app);
}
pub const tick = framework.noopTick(State);
pub fn handleAction(state: *State, app: *App, action: Action) void {
switch (action) {
.expand_collapse => toggleExpandAtCursor(state, app),
.sort_col_next => {
if (state.sort_field.next()) |new_field| {
state.sort_field = new_field;
state.sort_dir = if (new_field == .symbol or new_field == .account) .asc else .desc;
sortPortfolioAllocations(state, app);
rebuildPortfolioRows(state, app);
}
},
.sort_col_prev => {
if (state.sort_field.prev()) |new_field| {
state.sort_field = new_field;
state.sort_dir = if (new_field == .symbol or new_field == .account) .asc else .desc;
sortPortfolioAllocations(state, app);
rebuildPortfolioRows(state, app);
}
},
.sort_reverse => {
state.sort_dir = state.sort_dir.flip();
sortPortfolioAllocations(state, app);
rebuildPortfolioRows(state, app);
},
.open_account_picker => {
if (app.portfolio.file == null) return;
app.mode = .account_picker;
// Position cursor on the currently-active filter (or 0 for "All")
app.account_picker_cursor = 0;
if (state.account_filter) |af| {
for (state.account_list.items, 0..) |acct, ai| {
if (std.mem.eql(u8, acct, af)) {
app.account_picker_cursor = ai + 1; // +1 because 0 = "All accounts"
break;
}
}
}
},
.select_symbol => {
if (state.rows.items.len == 0) return;
if (state.cursor >= state.rows.items.len) return;
const row = state.rows.items[state.cursor];
app.setActiveSymbol(row.symbol);
var tmp_buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&tmp_buf, "Active: {s}", .{row.symbol}) catch "Active";
app.setStatus(msg);
},
}
}
/// Portfolio is always enabled (the tab itself; data may be
/// empty if no portfolio file is loaded that's a separate
/// concern handled by `drawWelcomeScreen`).
pub const isDisabled = framework.alwaysEnabled();
/// Sync the cursor to the new scroll extreme.
pub fn onScroll(state: *State, app: *App, where: framework.ScrollEdge) void {
_ = app;
switch (where) {
.top => state.cursor = 0,
.bottom => {
if (state.rows.items.len > 0) {
state.cursor = state.rows.items.len - 1;
}
},
}
}
/// Step the cursor by one row. Returns false when there are
/// no rows so the framework falls through to viewport scroll.
pub fn onCursorMove(state: *State, app: *App, delta: isize) bool {
if (state.rows.items.len == 0) return false;
if (delta > 0) {
if (state.cursor < state.rows.items.len - 1) state.cursor += 1;
} else {
if (state.cursor > 0) state.cursor -= 1;
}
ensureCursorVisible(state, &app.scroll_offset, app.visible_height);
return true;
}
/// Mouse handling: clicks on the column-header row sort by
/// that column; clicks on a data row move the cursor and
/// toggle expand/collapse. Returns `true` if consumed.
pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool {
if (mouse.button != .left) return false;
if (mouse.type != .press) return false;
const content_row = @as(usize, @intCast(mouse.row)) + app.scroll_offset;
// Click on the column-header row sort by that column.
if (state.header_lines > 0 and content_row == state.header_lines - 1) {
const col = @as(usize, @intCast(mouse.col));
const new_field: ?PortfolioSortField =
if (col < col_end_symbol)
.symbol
else if (col < col_end_shares)
.shares
else if (col < col_end_avg_cost)
.avg_cost
else if (col < col_end_price)
.price
else if (col < col_end_market_value)
.market_value
else if (col < col_end_gain_loss)
.gain_loss
else if (col < col_end_weight)
.weight
else if (col < col_end_date)
null // Date (not sortable)
else
.account;
if (new_field) |nf| {
if (nf == state.sort_field) {
state.sort_dir = state.sort_dir.flip();
} else {
state.sort_field = nf;
state.sort_dir = if (nf == .symbol or nf == .account) .asc else .desc;
}
sortPortfolioAllocations(state, app);
rebuildPortfolioRows(state, app);
return true;
}
return false;
}
// Click on a data row move cursor + toggle expand.
if (content_row >= state.header_lines and state.rows.items.len > 0) {
const line_idx = content_row - state.header_lines;
if (line_idx < state.line_count and line_idx < state.line_to_row.len) {
const row_idx = state.line_to_row[line_idx];
if (row_idx < state.rows.items.len) {
state.cursor = row_idx;
toggleExpandAtCursor(state, app);
return true;
}
}
}
return false;
}
};
fn ensureCursorVisible(state: *const State, scroll_offset: *usize, visible_height: usize) void {
const cursor_row = state.cursor + state.header_lines;
if (cursor_row < scroll_offset.*) {
scroll_offset.* = cursor_row;
}
const vis: usize = visible_height;
if (vis > 0 and cursor_row >= scroll_offset.* + vis) {
scroll_offset.* = cursor_row - vis + 1;
}
}
fn toggleExpandAtCursor(state: *State, app: *App) void {
if (state.rows.items.len == 0) return;
if (state.cursor >= state.rows.items.len) return;
const row = state.rows.items[state.cursor];
switch (row.kind) {
.position => {
// Single-lot positions don't expand
if (row.lot_count <= 1) return;
if (row.pos_idx < state.expanded.len) {
state.expanded[row.pos_idx] = !state.expanded[row.pos_idx];
rebuildPortfolioRows(state, app);
}
},
.lot, .option_row, .cd_row, .cash_row, .illiquid_row, .section_header, .drip_summary => {},
.cash_total => {
state.cash_expanded = !state.cash_expanded;
rebuildPortfolioRows(state, app);
},
.illiquid_total => {
state.illiquid_expanded = !state.illiquid_expanded;
rebuildPortfolioRows(state, app);
},
.watchlist => {
app.setActiveSymbol(row.symbol);
app.active_tab = .quote;
app.loadTabData();
},
}
}
/// 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;
@ -53,7 +392,7 @@ fn mapIntent(th: anytype, intent: fmt.StyleIntent) @import("vaxis").Style {
/// On first call, uses prefetched_prices (populated before TUI started).
/// On refresh, fetches live via svc.loadPrices. Tab switching skips this
/// entirely because the portfolio_loaded guard in loadTabData() short-circuits.
pub fn loadPortfolioData(app: *App) void {
pub fn loadPortfolioData(state: *State, app: *App) void {
app.portfolio.loaded = true;
app.freePortfolioSummary();
@ -81,7 +420,7 @@ pub fn loadPortfolioData(app: *App) void {
var stale_count: usize = 0;
var failed_syms: [8][]const u8 = undefined;
if (app.prefetched_prices) |*pp| {
if (state.prefetched_prices) |*pp| {
// Use pre-fetched prices from before TUI started (first load only)
// Move stock prices into the working map
for (syms) |sym| {
@ -103,7 +442,7 @@ pub fn loadPortfolioData(app: *App) void {
}
pp.deinit();
app.prefetched_prices = null;
state.prefetched_prices = null;
} else {
// Live fetch (refresh path) fetch watchlist first, then stock prices
if (app.portfolio.watchlist_prices) |*wp| wp.clearRetainingCapacity() else {
@ -195,10 +534,10 @@ pub fn loadPortfolioData(app: *App) void {
pf_data.candle_map.deinit();
}
sortPortfolioAllocations(app);
buildAccountList(app);
recomputeFilteredPositions(app);
rebuildPortfolioRows(app);
sortPortfolioAllocations(state, app);
buildAccountList(state, app);
recomputeFilteredPositions(state, app);
rebuildPortfolioRows(state, app);
const summary = pf_data.summary;
if (app.symbol.len == 0 and summary.allocations.len > 0) {
@ -251,7 +590,7 @@ pub fn loadPortfolioData(app: *App) void {
}
}
pub fn sortPortfolioAllocations(app: *App) void {
pub fn sortPortfolioAllocations(state: *State, app: *App) void {
if (app.portfolio.summary) |s| {
const SortCtx = struct {
field: PortfolioSortField,
@ -272,28 +611,31 @@ pub fn sortPortfolioAllocations(app: *App) void {
};
}
};
std.mem.sort(zfin.valuation.Allocation, s.allocations, SortCtx{ .field = app.portfolio_sort_field, .dir = app.portfolio_sort_dir }, SortCtx.lessThan);
std.mem.sort(zfin.valuation.Allocation, s.allocations, SortCtx{ .field = state.sort_field, .dir = state.sort_dir }, SortCtx.lessThan);
}
}
pub fn rebuildPortfolioRows(app: *App) void {
app.portfolio_rows.clearRetainingCapacity();
app.freePreparedSections();
pub fn rebuildPortfolioRows(state: *State, app: *App) void {
state.rows.clearRetainingCapacity();
if (state.prepared_options) |*opts| opts.deinit();
state.prepared_options = null;
if (state.prepared_cds) |*cds| cds.deinit();
state.prepared_cds = null;
if (app.portfolio.summary) |s| {
for (s.allocations, 0..) |a, i| {
// Skip allocations that don't match account filter
if (!allocationMatchesFilter(app, a)) continue;
if (!allocationMatchesFilter(state, a)) continue;
// Count lots for this symbol (filtered by account when filter is active)
var lcount: usize = 0;
if (app.filtered_positions) |fps| {
if (state.filtered_positions) |fps| {
for (fps) |pos| {
if (std.mem.eql(u8, pos.symbol, a.symbol) or std.mem.eql(u8, pos.lot_symbol, a.symbol)) {
lcount += pos.open_lots + pos.closed_lots;
}
}
} else if (app.account_filter == null) {
} else if (state.account_filter == null) {
if (app.portfolio.file) |pf| {
for (pf.lots) |lot| {
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
@ -303,7 +645,7 @@ pub fn rebuildPortfolioRows(app: *App) void {
}
}
app.portfolio_rows.append(app.allocator, .{
state.rows.append(app.allocator, .{
.kind = .position,
.symbol = a.symbol,
.pos_idx = i,
@ -311,14 +653,14 @@ pub fn rebuildPortfolioRows(app: *App) void {
}) catch continue;
// Only expand if multi-lot
if (lcount > 1 and i < app.expanded.len and app.expanded[i]) {
if (lcount > 1 and i < state.expanded.len and state.expanded[i]) {
if (app.portfolio.file) |pf| {
// Collect matching lots, sort: open first (date desc), then closed (date desc)
var matching: std.ArrayList(zfin.Lot) = .empty;
defer matching.deinit(app.allocator);
for (pf.lots) |lot| {
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
if (matchesAccountFilter(app, lot.account))
if (matchesAccountFilter(state, lot.account))
matching.append(app.allocator, lot) catch continue;
}
}
@ -336,7 +678,7 @@ pub fn rebuildPortfolioRows(app: *App) void {
if (!has_drip) {
// No DRIP lots: show all individually
for (matching.items) |lot| {
app.portfolio_rows.append(app.allocator, .{
state.rows.append(app.allocator, .{
.kind = .lot,
.symbol = lot.symbol,
.pos_idx = i,
@ -347,7 +689,7 @@ pub fn rebuildPortfolioRows(app: *App) void {
// Has DRIP lots: show non-DRIP individually, summarize DRIP as ST/LT
for (matching.items) |lot| {
if (!lot.drip) {
app.portfolio_rows.append(app.allocator, .{
state.rows.append(app.allocator, .{
.kind = .lot,
.symbol = lot.symbol,
.pos_idx = i,
@ -360,7 +702,7 @@ pub fn rebuildPortfolioRows(app: *App) void {
const drip = fmt.aggregateDripLots(app.today, matching.items);
if (!drip.st.isEmpty()) {
app.portfolio_rows.append(app.allocator, .{
state.rows.append(app.allocator, .{
.kind = .drip_summary,
.symbol = a.symbol,
.pos_idx = i,
@ -373,7 +715,7 @@ pub fn rebuildPortfolioRows(app: *App) void {
}) catch {};
}
if (!drip.lt.isEmpty()) {
app.portfolio_rows.append(app.allocator, .{
state.rows.append(app.allocator, .{
.kind = .drip_summary,
.symbol = a.symbol,
.pos_idx = i,
@ -394,7 +736,7 @@ pub fn rebuildPortfolioRows(app: *App) void {
// Add watchlist items from both the separate watchlist file and
// watch lots embedded in the portfolio. Skip symbols already in allocations.
// Hide watchlist entirely when account filter is active (watchlist items don't belong to accounts).
if (app.account_filter == null) {
if (state.account_filter == null) {
var watch_seen = std.StringHashMap(void).init(app.allocator);
defer watch_seen.deinit();
@ -411,7 +753,7 @@ pub fn rebuildPortfolioRows(app: *App) void {
if (lot.security_type == .watch) {
if (watch_seen.contains(lot.priceSymbol())) continue;
watch_seen.put(lot.priceSymbol(), {}) catch {};
app.portfolio_rows.append(app.allocator, .{
state.rows.append(app.allocator, .{
.kind = .watchlist,
.symbol = lot.symbol,
}) catch continue;
@ -424,7 +766,7 @@ pub fn rebuildPortfolioRows(app: *App) void {
for (wl) |sym| {
if (watch_seen.contains(sym)) continue;
watch_seen.put(sym, {}) catch {};
app.portfolio_rows.append(app.allocator, .{
state.rows.append(app.allocator, .{
.kind = .watchlist,
.symbol = sym,
}) catch continue;
@ -434,15 +776,15 @@ pub fn rebuildPortfolioRows(app: *App) void {
// Options section (sorted by expiration date, then symbol; filtered by account)
if (app.portfolio.file) |pf| {
app.prepared_options = views.Options.init(app.today, app.allocator, pf.lots, app.account_filter) catch null;
if (app.prepared_options) |opts| {
state.prepared_options = views.Options.init(app.today, app.allocator, pf.lots, state.account_filter) catch null;
if (state.prepared_options) |opts| {
if (opts.items.len > 0) {
app.portfolio_rows.append(app.allocator, .{
state.rows.append(app.allocator, .{
.kind = .section_header,
.symbol = "Options",
}) catch {};
for (opts.items) |po| {
app.portfolio_rows.append(app.allocator, .{
state.rows.append(app.allocator, .{
.kind = .option_row,
.symbol = po.lot.symbol,
.lot = po.lot,
@ -456,15 +798,15 @@ pub fn rebuildPortfolioRows(app: *App) void {
}
// CDs section (sorted by maturity date, earliest first; filtered by account)
app.prepared_cds = views.CDs.init(app.today, app.allocator, pf.lots, app.account_filter) catch null;
if (app.prepared_cds) |cds| {
state.prepared_cds = views.CDs.init(app.today, app.allocator, pf.lots, state.account_filter) catch null;
if (state.prepared_cds) |cds| {
if (cds.items.len > 0) {
app.portfolio_rows.append(app.allocator, .{
state.rows.append(app.allocator, .{
.kind = .section_header,
.symbol = "Certificates of Deposit",
}) catch {};
for (cds.items) |pc| {
app.portfolio_rows.append(app.allocator, .{
state.rows.append(app.allocator, .{
.kind = .cd_row,
.symbol = pc.lot.symbol,
.lot = pc.lot,
@ -478,21 +820,21 @@ pub fn rebuildPortfolioRows(app: *App) void {
// Cash section (filtered by account when filter is active)
if (pf.hasType(.cash)) {
// When filtered, only show cash lots matching the account
if (app.account_filter != null) {
if (state.account_filter != null) {
var cash_lots: std.ArrayList(zfin.Lot) = .empty;
defer cash_lots.deinit(app.allocator);
for (pf.lots) |lot| {
if (lot.security_type == .cash and matchesAccountFilter(app, lot.account)) {
if (lot.security_type == .cash and matchesAccountFilter(state, lot.account)) {
cash_lots.append(app.allocator, lot) catch continue;
}
}
if (cash_lots.items.len > 0) {
app.portfolio_rows.append(app.allocator, .{
state.rows.append(app.allocator, .{
.kind = .section_header,
.symbol = "Cash",
}) catch {};
for (cash_lots.items) |lot| {
app.portfolio_rows.append(app.allocator, .{
state.rows.append(app.allocator, .{
.kind = .cash_row,
.symbol = lot.account orelse "Unknown",
.lot = lot,
@ -501,18 +843,18 @@ pub fn rebuildPortfolioRows(app: *App) void {
}
} else {
// Unfiltered: show total + expandable per-account rows
app.portfolio_rows.append(app.allocator, .{
state.rows.append(app.allocator, .{
.kind = .section_header,
.symbol = "Cash",
}) catch {};
app.portfolio_rows.append(app.allocator, .{
state.rows.append(app.allocator, .{
.kind = .cash_total,
.symbol = "CASH",
}) catch {};
if (app.cash_expanded) {
if (state.cash_expanded) {
for (pf.lots) |lot| {
if (lot.security_type == .cash) {
app.portfolio_rows.append(app.allocator, .{
state.rows.append(app.allocator, .{
.kind = .cash_row,
.symbol = lot.account orelse "Unknown",
.lot = lot,
@ -524,20 +866,20 @@ pub fn rebuildPortfolioRows(app: *App) void {
}
// Illiquid assets section (hidden when account filter is active)
if (app.account_filter == null) {
if (state.account_filter == null) {
if (pf.hasType(.illiquid)) {
app.portfolio_rows.append(app.allocator, .{
state.rows.append(app.allocator, .{
.kind = .section_header,
.symbol = "Illiquid Assets",
}) catch {};
app.portfolio_rows.append(app.allocator, .{
state.rows.append(app.allocator, .{
.kind = .illiquid_total,
.symbol = "ILLIQUID",
}) catch {};
if (app.illiquid_expanded) {
if (state.illiquid_expanded) {
for (pf.lots) |lot| {
if (lot.security_type == .illiquid) {
app.portfolio_rows.append(app.allocator, .{
state.rows.append(app.allocator, .{
.kind = .illiquid_row,
.symbol = lot.symbol,
.lot = lot,
@ -553,10 +895,10 @@ pub fn rebuildPortfolioRows(app: *App) void {
/// Build the ordered list of distinct account names from portfolio lots.
/// Order: accounts.srf file order first, then any remaining accounts alphabetically.
/// Also assigns shortcut keys and loads account numbers from accounts.srf.
pub fn buildAccountList(app: *App) void {
app.account_list.clearRetainingCapacity();
app.account_numbers.clearRetainingCapacity();
app.account_shortcut_keys.clearRetainingCapacity();
pub fn buildAccountList(state: *State, app: *App) void {
state.account_list.clearRetainingCapacity();
state.account_numbers.clearRetainingCapacity();
state.account_shortcut_keys.clearRetainingCapacity();
const pf = app.portfolio.file orelse return;
@ -582,8 +924,8 @@ pub fn buildAccountList(app: *App) void {
if (app.portfolio.account_map) |am| {
for (am.entries) |entry| {
if (seen.contains(entry.account)) {
app.account_list.append(app.allocator, entry.account) catch continue;
app.account_numbers.append(app.allocator, entry.account_number) catch continue;
state.account_list.append(app.allocator, entry.account) catch continue;
state.account_numbers.append(app.allocator, entry.account_number) catch continue;
}
}
}
@ -594,7 +936,7 @@ pub fn buildAccountList(app: *App) void {
for (lot_accounts.items) |acct| {
var found = false;
for (app.account_list.items) |existing| {
for (state.account_list.items) |existing| {
if (std.mem.eql(u8, acct, existing)) {
found = true;
break;
@ -610,17 +952,17 @@ pub fn buildAccountList(app: *App) void {
}.lessThan);
for (extras.items) |acct| {
app.account_list.append(app.allocator, acct) catch continue;
app.account_numbers.append(app.allocator, null) catch continue;
state.account_list.append(app.allocator, acct) catch continue;
state.account_numbers.append(app.allocator, null) catch continue;
}
// Assign shortcut keys: 1-9, 0, then b-z (skipping conflict keys)
assignShortcutKeys(app);
assignShortcutKeys(state, app);
// If the current filter no longer exists in the new list, clear it
if (app.account_filter) |af| {
if (state.account_filter) |af| {
var found = false;
for (app.account_list.items) |acct| {
for (state.account_list.items) |acct| {
if (std.mem.eql(u8, acct, af)) {
found = true;
break;
@ -632,41 +974,41 @@ pub fn buildAccountList(app: *App) void {
const shortcut_key_order = "1234567890bcdefhimnoptuvwxyz";
fn assignShortcutKeys(app: *App) void {
app.account_shortcut_keys.clearRetainingCapacity();
fn assignShortcutKeys(state: *State, app: *App) void {
state.account_shortcut_keys.clearRetainingCapacity();
var key_idx: usize = 0;
for (0..app.account_list.items.len) |_| {
for (0..state.account_list.items.len) |_| {
if (key_idx < shortcut_key_order.len) {
app.account_shortcut_keys.append(app.allocator, shortcut_key_order[key_idx]) catch continue;
state.account_shortcut_keys.append(app.allocator, shortcut_key_order[key_idx]) catch continue;
key_idx += 1;
} else {
app.account_shortcut_keys.append(app.allocator, 0) catch continue;
state.account_shortcut_keys.append(app.allocator, 0) catch continue;
}
}
}
/// Recompute filtered_positions when portfolio or account filter changes.
fn recomputeFilteredPositions(app: *App) void {
if (app.filtered_positions) |fp| app.allocator.free(fp);
app.filtered_positions = null;
const filter = app.account_filter orelse return;
fn recomputeFilteredPositions(state: *State, app: *App) void {
if (state.filtered_positions) |fp| app.allocator.free(fp);
state.filtered_positions = null;
const filter = state.account_filter orelse return;
const pf = app.portfolio.file orelse return;
app.filtered_positions = pf.positionsForAccount(app.today, app.allocator, filter) catch null;
state.filtered_positions = pf.positionsForAccount(app.today, app.allocator, filter) catch null;
}
/// Check if a lot matches the active account filter.
/// Returns true if no filter is active or the lot's account matches.
fn matchesAccountFilter(app: *const App, account: ?[]const u8) bool {
const filter = app.account_filter orelse return true;
fn matchesAccountFilter(state: *const State, account: ?[]const u8) bool {
const filter = state.account_filter orelse return true;
const acct = account orelse return false;
return std.mem.eql(u8, acct, filter);
}
/// Check if an allocation matches the active account filter.
/// When filtered, checks against pre-computed filtered_positions.
fn allocationMatchesFilter(app: *const App, a: zfin.valuation.Allocation) bool {
if (app.account_filter == null) return true;
const fps = app.filtered_positions orelse return false;
fn allocationMatchesFilter(state: *const State, a: zfin.valuation.Allocation) bool {
if (state.account_filter == null) return true;
const fps = state.filtered_positions orelse return false;
for (fps) |pos| {
if (std.mem.eql(u8, pos.symbol, a.symbol) or std.mem.eql(u8, pos.lot_symbol, a.symbol))
return true;
@ -688,14 +1030,14 @@ const FilteredAlloc = struct {
/// For filtered views, sums across all matching positions for the symbol.
/// This handles rolled-up allocations where multiple positions with different
/// price_ratios share the same ticker (e.g. direct SPY + institutional CIT).
fn filteredAllocValues(app: *const App, a: zfin.valuation.Allocation) FilteredAlloc {
if (app.account_filter == null) return .{
fn filteredAllocValues(state: *const State, a: zfin.valuation.Allocation) FilteredAlloc {
if (state.account_filter == null) return .{
.shares = a.shares,
.cost_basis = a.cost_basis,
.market_value = a.market_value,
.unrealized_gain_loss = a.unrealized_gain_loss,
};
const fps = app.filtered_positions orelse return .{
const fps = state.filtered_positions orelse return .{
.shares = 0,
.cost_basis = 0,
.market_value = 0,
@ -741,14 +1083,14 @@ const FilteredTotals = struct {
/// Compute total value and cost across all asset types for the active account filter.
/// Returns {0, 0} if no filter is active.
fn computeFilteredTotals(app: *const App) FilteredTotals {
const af = app.account_filter orelse return .{ .value = 0, .cost = 0 };
fn computeFilteredTotals(state: *const State, app: *const App) FilteredTotals {
const af = state.account_filter orelse return .{ .value = 0, .cost = 0 };
var value: f64 = 0;
var cost: f64 = 0;
if (app.portfolio.summary) |s| {
for (s.allocations) |a| {
if (allocationMatchesFilter(app, a)) {
const fa = filteredAllocValues(app, a);
if (allocationMatchesFilter(state, a)) {
const fa = filteredAllocValues(state, a);
value += fa.market_value;
cost += fa.cost_basis;
}
@ -764,7 +1106,7 @@ fn computeFilteredTotals(app: *const App) FilteredTotals {
// Rendering
pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
const th = app.theme;
if (app.portfolio.file == null and app.watchlist == null) {
@ -776,9 +1118,9 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
if (app.portfolio.summary) |s| {
if (app.account_filter) |af| {
if (state.account_filter) |af| {
// Filtered mode: compute account-specific totals
const ft = computeFilteredTotals(app);
const ft = computeFilteredTotals(state, app);
const filtered_value = ft.value;
const filtered_cost = ft.cost;
const filtered_gl = filtered_value - filtered_cost;
@ -864,8 +1206,8 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
// 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 = app.portfolio_sort_field;
const si = app.portfolio_sort_dir.indicator();
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;
@ -890,19 +1232,19 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
try lines.append(arena, .{ .text = hdr, .style = th.headerStyle() });
// Track header line count for mouse click mapping (after all header lines)
app.portfolio_header_lines = lines.items.len;
app.portfolio_line_count = 0;
state.header_lines = lines.items.len;
state.line_count = 0;
// Compute filtered total value for account-relative weight calculation
const filtered_total_for_weight: f64 = if (app.account_filter != null)
computeFilteredTotals(app).value
const filtered_total_for_weight: f64 = if (state.account_filter != null)
computeFilteredTotals(state, app).value
else
0;
// Data rows
for (app.portfolio_rows.items, 0..) |row, ri| {
for (state.rows.items, 0..) |row, ri| {
const lines_before = lines.items.len;
const is_cursor = ri == app.cursor;
const is_cursor = ri == state.cursor;
const is_active_sym = std.mem.eql(u8, row.symbol, app.symbol);
switch (row.kind) {
.position => {
@ -910,14 +1252,14 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
if (row.pos_idx < s.allocations.len) {
const a = s.allocations[row.pos_idx];
// Use account-filtered values for multi-account positions
const fa = filteredAllocValues(app, a);
const fa = filteredAllocValues(state, a);
const display_shares = fa.shares;
const display_avg_cost = if (fa.shares > 0) fa.cost_basis / fa.shares else a.avg_cost;
const display_mv = fa.market_value;
const display_gl = fa.unrealized_gain_loss;
const is_multi = row.lot_count > 1;
const is_expanded = is_multi and row.pos_idx < app.expanded.len and app.expanded[row.pos_idx];
const is_expanded = is_multi and row.pos_idx < state.expanded.len and state.expanded[row.pos_idx];
const arrow: []const u8 = if (!is_multi) " " else if (is_expanded) "v " else "> ";
const star: []const u8 = if (is_active_sym) "* " else " ";
const pnl_pct = if (fa.cost_basis > 0) (display_gl / fa.cost_basis) * 100.0 else @as(f64, 0);
@ -944,7 +1286,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
if (app.portfolio.file) |pf| {
for (pf.lots) |lot| {
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
if (matchesAccountFilter(app, lot.account)) {
if (matchesAccountFilter(state, lot.account)) {
const ds = std.fmt.bufPrint(&pos_date_buf, "{f}", .{lot.open_date}) catch "????-??-??";
const indicator = fmt.capitalGainsIndicator(app.today, lot.open_date);
date_col = std.fmt.allocPrint(arena, "{s} {s}", .{ ds, indicator }) catch ds;
@ -954,13 +1296,13 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
}
}
}
} else if (app.account_filter) |af| {
} else if (state.account_filter) |af| {
acct_col = af;
} else {
acct_col = a.account;
}
const display_weight = if (app.account_filter != null and filtered_total_for_weight > 0)
const display_weight = if (state.account_filter != null and filtered_total_for_weight > 0)
(display_mv / filtered_total_for_weight)
else
a.weight;
@ -1077,7 +1419,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
.cash_total => {
if (app.portfolio.file) |pf| {
const total_cash = pf.totalCash(app.today);
const arrow3: []const u8 = if (app.cash_expanded) "v " else "> ";
const arrow3: []const u8 = if (state.cash_expanded) "v " else "> ";
const text = try std.fmt.allocPrint(arena, " {s}Total Cash {f}", .{
arrow3,
Money.from(total_cash).padRight(14),
@ -1098,7 +1440,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
.illiquid_total => {
if (app.portfolio.file) |pf| {
const total_illiquid = pf.totalIlliquid(app.today);
const arrow4: []const u8 = if (app.illiquid_expanded) "v " else "> ";
const arrow4: []const u8 = if (state.illiquid_expanded) "v " else "> ";
const text = try std.fmt.allocPrint(arena, " {s}Total Illiquid {f}", .{
arrow4,
Money.from(total_illiquid).padRight(14),
@ -1133,12 +1475,12 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
// Map all styled lines produced by this row back to the row index
const lines_after = lines.items.len;
for (lines_before..lines_after) |li| {
const map_idx = li - app.portfolio_header_lines;
if (map_idx < app.portfolio_line_to_row.len) {
app.portfolio_line_to_row[map_idx] = ri;
const map_idx = li - state.header_lines;
if (map_idx < state.line_to_row.len) {
state.line_to_row[map_idx] = ri;
}
}
app.portfolio_line_count = lines_after - app.portfolio_header_lines;
state.line_count = lines_after - state.header_lines;
}
// Render
@ -1179,11 +1521,11 @@ fn drawWelcomeScreen(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, wid
/// Reload portfolio file from disk without re-fetching prices.
/// Uses cached candle data to recompute summary.
pub fn reloadPortfolioFile(app: *App) void {
pub fn reloadPortfolioFile(state: *State, app: *App) void {
// Save the account filter name before freeing the old portfolio.
// account_filter is an owned copy so it survives the portfolio free,
// but account_list entries borrow from the portfolio and will dangle.
app.account_list.clearRetainingCapacity();
state.account_list.clearRetainingCapacity();
// Re-read the portfolio file
if (app.portfolio.file) |*pf| pf.deinit();
@ -1214,12 +1556,12 @@ pub fn reloadPortfolioFile(app: *App) void {
// Recompute summary using cached prices (no network)
app.freePortfolioSummary();
app.expanded = @splat(false);
app.cash_expanded = false;
app.illiquid_expanded = false;
app.cursor = 0;
state.expanded = @splat(false);
state.cash_expanded = false;
state.illiquid_expanded = false;
state.cursor = 0;
app.scroll_offset = 0;
app.portfolio_rows.clearRetainingCapacity();
state.rows.clearRetainingCapacity();
const pf = app.portfolio.file orelse return;
const positions = pf.positions(app.today, app.allocator) catch {
@ -1278,10 +1620,10 @@ pub fn reloadPortfolioFile(app: *App) void {
pf_data.candle_map.deinit();
}
sortPortfolioAllocations(app);
buildAccountList(app);
recomputeFilteredPositions(app);
rebuildPortfolioRows(app);
sortPortfolioAllocations(state, app);
buildAccountList(state, app);
recomputeFilteredPositions(state, app);
rebuildPortfolioRows(state, app);
// Invalidate analysis data -- it holds pointers into old portfolio memory
if (app.states.analysis.result) |*ar| ar.deinit(app.allocator);
@ -1324,7 +1666,7 @@ pub fn reloadPortfolioFile(app: *App) void {
pub const account_picker_header_lines: usize = 3;
/// Draw the account picker overlay (replaces portfolio content).
pub fn drawAccountPicker(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
pub fn drawAccountPicker(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
const th = app.theme;
var lines: std.ArrayList(tui.StyledLine) = .empty;
@ -1345,7 +1687,7 @@ pub fn drawAccountPicker(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell,
}
// Item 0 = "All accounts" (clears filter)
const total_items = app.account_list.items.len + 1;
const total_items = state.account_list.items.len + 1;
for (0..total_items) |i| {
const is_selected = if (is_searching)
(if (search_cursor_idx) |sci| i == sci + 1 else false)
@ -1360,9 +1702,9 @@ pub fn drawAccountPicker(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell,
try lines.append(arena, .{ .text = text, .style = if (dimmed) th.mutedStyle() else style });
} else {
const acct_idx = i - 1;
const label = app.account_list.items[acct_idx];
const shortcut: u8 = if (acct_idx < app.account_shortcut_keys.items.len) app.account_shortcut_keys.items[acct_idx] else 0;
const acct_num: ?[]const u8 = if (acct_idx < app.account_numbers.items.len) app.account_numbers.items[acct_idx] else null;
const label = state.account_list.items[acct_idx];
const shortcut: u8 = if (acct_idx < state.account_shortcut_keys.items.len) state.account_shortcut_keys.items[acct_idx] else 0;
const acct_num: ?[]const u8 = if (acct_idx < state.account_numbers.items.len) state.account_numbers.items[acct_idx] else null;
const text = if (acct_num) |num|
(if (shortcut != 0)