zfin/src/tui/portfolio_tab.zig

2346 lines
102 KiB
Zig

const std = @import("std");
const vaxis = @import("vaxis");
const zfin = @import("../root.zig");
const fmt = @import("../format.zig");
const Money = @import("../Money.zig");
const views = @import("../views/portfolio_sections.zig");
const cli = @import("../commands/common.zig");
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 colLabel = tui.colLabel;
const glyph = tui.glyph;
// Portfolio column layout (display columns).
// Each column width includes its trailing separator space.
// prefix(4) + sym(sw+1) + shares(8+1) + avgcost(10+1) + price(10+1) + mv(16+1) + gl(14+1) + weight(8) + date(13+1) + account
const prefix_cols: usize = 4;
const sw: usize = fmt.sym_col_width;
// ── Portfolio-specific types ──────────────────────────────────
/// Sortable columns in the portfolio table. Bound to the
/// `sort_col_next` / `sort_col_prev` actions; the `sort_reverse`
/// action flips the current `SortDirection`.
pub const PortfolioSortField = enum {
symbol,
shares,
avg_cost,
price,
market_value,
gain_loss,
weight,
account,
pub fn label(self: PortfolioSortField) []const u8 {
return switch (self) {
.symbol => "Symbol",
.shares => "Shares",
.avg_cost => "Avg Cost",
.price => "Price",
.market_value => "Market Value",
.gain_loss => "Gain/Loss",
.weight => "Weight",
.account => "Account",
};
}
pub fn next(self: PortfolioSortField) ?PortfolioSortField {
const fields = std.meta.fields(PortfolioSortField);
const idx: usize = @intFromEnum(self);
if (idx + 1 >= fields.len) return null;
return @enumFromInt(idx + 1);
}
pub fn prev(self: PortfolioSortField) ?PortfolioSortField {
const idx: usize = @intFromEnum(self);
if (idx == 0) return null;
return @enumFromInt(idx - 1);
}
};
/// Sort direction for the portfolio table.
pub const SortDirection = enum {
asc,
desc,
pub fn flip(self: SortDirection) SortDirection {
return if (self == .asc) .desc else .asc;
}
pub fn indicator(self: SortDirection) []const u8 {
return if (self == .asc) "" else "";
}
};
/// One row in the portfolio table's flattened display list.
/// Covers position rows, lot rows (when expanded), watchlist
/// entries, section headers, options/CDs/cash/illiquid summary
/// rows, and DRIP-summary rows. Rebuilt by
/// `rebuildPortfolioRows` whenever sort / filter / expansion
/// changes.
pub const PortfolioRow = struct {
kind: Kind,
symbol: []const u8,
/// For position rows: index into allocations; for lot rows: lot data.
pos_idx: usize = 0,
lot: ?zfin.Lot = null,
/// Number of lots for this symbol (set on position rows)
lot_count: usize = 0,
/// DRIP summary data (for drip_summary rows)
drip_is_lt: bool = false, // true = LT summary, false = ST summary
drip_lot_count: usize = 0,
drip_shares: f64 = 0,
drip_avg_cost: f64 = 0,
drip_date_first: ?zfin.Date = null,
drip_date_last: ?zfin.Date = null,
/// Pre-formatted text from view model (options and CDs)
prepared_text: ?[]const u8 = null,
/// Semantic styles from view model
row_style: fmt.StyleIntent = .normal,
premium_style: fmt.StyleIntent = .normal,
/// Column offset for premium alt-style coloring (options only)
premium_col_start: usize = 0,
const Kind = enum { position, lot, watchlist, section_header, option_row, cd_row, cash_row, cash_total, illiquid_row, illiquid_total, drip_summary };
};
// ── 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 (state.modal = .account_picker).
/// No-op if no portfolio is loaded.
open_account_picker,
/// Clear the active account filter (return to "all accounts").
/// No-op when no filter is active. Bound to Esc.
clear_account_filter,
/// 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 ─────────────────────────────────────────
/// Tab-internal modal sub-state. The picker is "modal" only from
/// the portfolio tab's perspective — App treats the tab the same
/// as any other; the tab itself swallows input and re-routes
/// drawing while a modal is active. App.Mode does NOT carry
/// these variants.
pub const Modal = enum {
/// No modal active. Normal portfolio behavior (table view +
/// keymap actions).
none,
/// Account-picker overlay open. Picker keys (j/k/Enter/Esc/...)
/// are routed to `handleAccountPickerKey`; everything else is
/// swallowed so global actions don't fire underneath.
account_picker,
/// Search-within-picker active (entered from picker via `/`).
/// Routed to `handleAccountSearchKey`.
account_search,
};
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,
// ── Account picker / search modal ──
//
// The portfolio tab owns picker state in full: the cursor,
// search buffer, and the modal sub-state itself
// (`state.modal`). The picker is "modal" only from
// portfolio's perspective — the framework treats the tab the
// same as any other; portfolio's own `handleKey` /
// `handleMouse` / `drawContent` / `drawStatusBar` check
// `state.modal` and route accordingly. App.Mode does NOT
// carry picker variants.
/// Cursor position in the picker (0 = "All accounts", N = nth
/// account in `account_list` shifted by 1).
account_picker_cursor: usize = 0,
/// Search-mode input buffer (active when
/// `state.modal == .account_search`).
account_search_buf: [64]u8 = undefined,
/// Live length of `account_search_buf`.
account_search_len: usize = 0,
/// Indices into `account_list` that match the current search
/// query.
account_search_matches: std.ArrayList(usize) = .empty,
/// Cursor within `account_search_matches` (which match is
/// currently highlighted).
account_search_cursor: usize = 0,
/// Tab-internal modal sub-state (account picker / search).
/// See `Modal` for variants. App's draw / event dispatchers
/// remain mode-agnostic; portfolio's own `handleKey` /
/// `handleMouse` / `drawContent` / `drawStatusBar` check
/// this and route accordingly.
modal: Modal = .none,
};
// ── Tab framework contract ────────────────────────────────────
pub const meta: framework.TabMeta(Action) = .{
.label = "Portfolio",
.default_bindings = &.{
.{ .action = .expand_collapse, .key = .{ .codepoint = vaxis.Key.enter } },
.{ .action = .sort_col_next, .key = .{ .codepoint = '>' } },
.{ .action = .sort_col_prev, .key = .{ .codepoint = '<' } },
.{ .action = .sort_reverse, .key = .{ .codepoint = 'o' } },
.{ .action = .open_account_picker, .key = .{ .codepoint = 'a' } },
.{ .action = .clear_account_filter, .key = .{ .codepoint = vaxis.Key.escape } },
.{ .action = .select_symbol, .key = .{ .codepoint = 's' } },
.{ .action = .select_symbol, .key = .{ .codepoint = vaxis.Key.space } },
},
.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",
.clear_account_filter = "Clear account filter",
.select_symbol = "Select symbol",
}),
.status_hints = &.{
.sort_col_prev,
.sort_col_next,
.sort_reverse,
.open_account_picker,
},
};
pub const tab = struct {
pub const ActionT = Action;
pub const StateT = State;
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.account_search_matches.deinit(app.allocator);
state.* = .{};
}
pub fn activate(state: *State, app: *App) !void {
// `loadPortfolioData` calls `ensurePortfolioDataLoaded`
// (idempotent — short-circuits when data is already
// loaded) and then unconditionally rebuilds the
// portfolio-tab UI state (sort, account list, rows).
// Skipping the UI rebuild on cache hit would leave a
// freshly-activated tab with no rows when the data was
// pre-loaded by App startup or another tab.
loadPortfolioData(state, app);
}
pub const deactivate = framework.noopDeactivate(State);
/// Manual refresh (r/F5): drop the cached aggregate summary
/// and re-fetch live prices via `loadPortfolioData`. Distinct
/// from `reloadPortfolioFile` (R), which re-reads
/// `portfolio.srf` from disk. The framework calls this from
/// `refreshCurrentTab`; the file-reload path has its own
/// separate action.
pub fn reload(state: *State, app: *App) !void {
app.portfolio.loaded = false;
app.freePortfolioSummary();
loadPortfolioData(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;
state.modal = .account_picker;
// Position cursor on the currently-active filter (or 0 for "All")
state.account_picker_cursor = 0;
if (state.account_filter) |af| {
for (state.account_list.items, 0..) |acct, ai| {
if (std.mem.eql(u8, acct, af)) {
state.account_picker_cursor = ai + 1; // +1 because 0 = "All accounts"
break;
}
}
}
},
.clear_account_filter => {
// No-op when no filter is active.
if (state.account_filter == null) return;
setAccountFilter(state, app, null);
state.cursor = 0;
app.scroll_offset = 0;
rebuildPortfolioRows(state, app);
app.setStatus("Filter cleared: showing all accounts");
},
.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;
}
/// Pre-empt key handler. Called by the framework BEFORE
/// global keymap matching runs. When portfolio is in a
/// modal sub-state (`state.modal != .none`) we route to the
/// modal's key handler and consume the event so global
/// actions (refresh, tab switch, etc) don't fire underneath.
/// When not in a modal, we return `false` so dispatch falls
/// through to the normal global → tab-local path.
pub fn handleKey(state: *State, app: *App, key: vaxis.Key) bool {
return switch (state.modal) {
.none => false,
.account_picker => handleAccountPickerKey(state, app, key),
.account_search => handleAccountSearchKey(state, app, key),
};
}
/// Status-bar override. The picker and search modals get
/// their own hint line; otherwise the App-level default
/// status applies.
pub fn statusOverride(state: *State, app: *App) ?framework.StatusOverride {
_ = app;
return switch (state.modal) {
.none => null,
.account_picker => .{ .hint = " j/k=navigate Enter=select Esc=cancel /=search Click=select " },
.account_search => .{ .hint = " type to filter Enter=select Esc=cancel Ctrl+N/Ctrl+P=cycle " },
};
}
/// Mouse handling. In account-picker mode, drives the modal
/// (wheel scroll, click-to-select). Otherwise: 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 {
// Account picker modal — swallows all mouse events when
// active, regardless of where they land. This includes
// tab-bar clicks (row 0): the modal blocks tab switching
// until dismissed.
if (state.modal != .none) {
return handleAccountPickerMouse(state, app, mouse);
}
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;
}
};
/// Adjust `scroll_offset` so the cursor row (`state.cursor +
/// state.header_lines`) is visible within `visible_height`. Pure
/// over (state, scroll_offset, visible_height); mutates
/// `scroll_offset` only.
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;
pub const col_end_avg_cost: usize = col_end_shares + 11;
pub const col_end_price: usize = col_end_avg_cost + 11;
pub const col_end_market_value: usize = col_end_price + 17;
pub const col_end_gain_loss: usize = col_end_market_value + 15;
pub const col_end_weight: usize = col_end_gain_loss + 9;
pub const col_end_date: usize = col_end_weight + 14;
// Gain/loss column start position (used for alt-style coloring)
const gl_col_start: usize = col_end_market_value;
/// Map a semantic StyleIntent to a platform-specific vaxis style.
fn mapIntent(th: theme.Theme, intent: fmt.StyleIntent) vaxis.Style {
return th.styleFor(intent);
}
// ── Data loading ──────────────────────────────────────────────
/// Load portfolio data: prices, summary, candle map, and historical snapshots.
///
/// Call paths:
/// 1. First tab visit: loadTabData() → here (guarded by portfolio_loaded flag)
/// 2. Manual refresh (r/F5): refreshCurrentTab() clears portfolio_loaded → loadTabData() → here
/// 3. Disk reload (R): reloadPortfolioFile() — separate function, cache-only, no network
///
/// 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.
/// Set up the portfolio tab's UI state from current
/// `app.portfolio` data: sort allocations per current sort
/// field, build the account list, recompute filtered positions
/// (when an account filter is active), and rebuild the styled
/// row list. Called from `activate` AFTER
/// `app.ensurePortfolioDataLoaded()`. No-op when no summary is
/// available.
///
/// Call paths:
/// 1. First tab visit: `tab.activate` → here
/// 2. Manual refresh (r/F5): `tab.reload` clears
/// `app.portfolio.loaded` → `tab.activate` → ensurePortfolioDataLoaded → here
/// 3. Disk reload (R): `reloadPortfolioFile` — separate
/// function, cache-only, no network
///
/// Tab switching skips this entirely because `tab.activate`'s
/// own guard short-circuits when `state.loaded` (TODO: this
/// flag doesn't exist yet on portfolio.State; today the guard
/// is `app.portfolio.loaded` which `ensurePortfolioDataLoaded`
/// owns. Visiting portfolio after analysis pre-loaded the data
/// will still rebuild the row list — cheap.)
pub fn loadPortfolioData(state: *State, app: *App) void {
app.ensurePortfolioDataLoaded();
// App may have failed to load — check before touching summary.
const summary = app.portfolio.summary orelse return;
sortPortfolioAllocations(state, app);
buildAccountList(state, app);
recomputeFilteredPositions(state, app);
rebuildPortfolioRows(state, app);
// Pre-select the first row when no symbol is active yet.
// Runs AFTER `sortPortfolioAllocations` so the default
// matches what the user sees at the top of the table —
// alphabetically first by symbol with the default sort,
// not whatever lot happens to appear first in
// `portfolio.srf`. This is the "user just started the TUI;
// pick something sensible" path; once `app.symbol` is set
// (by user action or `--symbol`), this is a no-op.
if (app.symbol.len == 0 and summary.allocations.len > 0) {
app.setActiveSymbol(summary.allocations[0].symbol);
}
}
pub fn sortPortfolioAllocations(state: *State, app: *App) void {
if (app.portfolio.summary) |s| {
const SortCtx = struct {
field: PortfolioSortField,
dir: SortDirection,
fn lessThan(ctx: @This(), a: zfin.valuation.Allocation, b: zfin.valuation.Allocation) bool {
const lhs = if (ctx.dir == .asc) a else b;
const rhs = if (ctx.dir == .asc) b else a;
return switch (ctx.field) {
.symbol => std.mem.lessThan(u8, lhs.display_symbol, rhs.display_symbol),
.shares => lhs.shares < rhs.shares,
.avg_cost => lhs.avg_cost < rhs.avg_cost,
.price => lhs.current_price < rhs.current_price,
.market_value => lhs.market_value < rhs.market_value,
.gain_loss => lhs.unrealized_gain_loss < rhs.unrealized_gain_loss,
.weight => lhs.weight < rhs.weight,
.account => std.mem.lessThan(u8, lhs.account, rhs.account),
};
}
};
std.mem.sort(zfin.valuation.Allocation, s.allocations, SortCtx{ .field = state.sort_field, .dir = state.sort_dir }, SortCtx.lessThan);
}
}
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(state, a)) continue;
// Count lots for this symbol (filtered by account when filter is active)
var lcount: usize = 0;
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 (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)) {
lcount += 1;
}
}
}
}
state.rows.append(app.allocator, .{
.kind = .position,
.symbol = a.symbol,
.pos_idx = i,
.lot_count = lcount,
}) catch continue;
// Only expand if multi-lot
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(state, lot.account))
matching.append(app.allocator, lot) catch continue;
}
}
std.mem.sort(zfin.Lot, matching.items, app.today, fmt.lotSortFn);
// Check if any lots are DRIP
var has_drip = false;
for (matching.items) |lot| {
if (lot.drip) {
has_drip = true;
break;
}
}
if (!has_drip) {
// No DRIP lots: show all individually
for (matching.items) |lot| {
state.rows.append(app.allocator, .{
.kind = .lot,
.symbol = lot.symbol,
.pos_idx = i,
.lot = lot,
}) catch continue;
}
} else {
// Has DRIP lots: show non-DRIP individually, summarize DRIP as ST/LT
for (matching.items) |lot| {
if (!lot.drip) {
state.rows.append(app.allocator, .{
.kind = .lot,
.symbol = lot.symbol,
.pos_idx = i,
.lot = lot,
}) catch continue;
}
}
// Build ST and LT DRIP summaries
const drip = fmt.aggregateDripLots(app.today, matching.items);
if (!drip.st.isEmpty()) {
state.rows.append(app.allocator, .{
.kind = .drip_summary,
.symbol = a.symbol,
.pos_idx = i,
.drip_is_lt = false,
.drip_lot_count = drip.st.lot_count,
.drip_shares = drip.st.shares,
.drip_avg_cost = drip.st.avgCost(),
.drip_date_first = drip.st.first_date,
.drip_date_last = drip.st.last_date,
}) catch {};
}
if (!drip.lt.isEmpty()) {
state.rows.append(app.allocator, .{
.kind = .drip_summary,
.symbol = a.symbol,
.pos_idx = i,
.drip_is_lt = true,
.drip_lot_count = drip.lt.lot_count,
.drip_shares = drip.lt.shares,
.drip_avg_cost = drip.lt.avgCost(),
.drip_date_first = drip.lt.first_date,
.drip_date_last = drip.lt.last_date,
}) catch {};
}
}
}
}
}
}
// 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 (state.account_filter == null) {
var watch_seen = std.StringHashMap(void).init(app.allocator);
defer watch_seen.deinit();
// Mark all portfolio position symbols as seen
if (app.portfolio.summary) |s| {
for (s.allocations) |a| {
watch_seen.put(a.symbol, {}) catch {};
}
}
// Watch lots from portfolio file
if (app.portfolio.file) |pf| {
for (pf.lots) |lot| {
if (lot.security_type == .watch) {
if (watch_seen.contains(lot.priceSymbol())) continue;
watch_seen.put(lot.priceSymbol(), {}) catch {};
state.rows.append(app.allocator, .{
.kind = .watchlist,
.symbol = lot.symbol,
}) catch continue;
}
}
}
// Separate watchlist file (backward compat)
if (app.watchlist) |wl| {
for (wl) |sym| {
if (watch_seen.contains(sym)) continue;
watch_seen.put(sym, {}) catch {};
state.rows.append(app.allocator, .{
.kind = .watchlist,
.symbol = sym,
}) catch continue;
}
}
}
// Options section (sorted by expiration date, then symbol; filtered by account)
if (app.portfolio.file) |pf| {
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) {
state.rows.append(app.allocator, .{
.kind = .section_header,
.symbol = "Options",
}) catch {};
for (opts.items) |po| {
state.rows.append(app.allocator, .{
.kind = .option_row,
.symbol = po.lot.symbol,
.lot = po.lot,
.prepared_text = po.columns[0].text,
.row_style = po.row_style,
.premium_style = po.premium_style,
.premium_col_start = po.premium_col_start,
}) catch continue;
}
}
}
// CDs section (sorted by maturity date, earliest first; filtered by account)
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) {
state.rows.append(app.allocator, .{
.kind = .section_header,
.symbol = "Certificates of Deposit",
}) catch {};
for (cds.items) |pc| {
state.rows.append(app.allocator, .{
.kind = .cd_row,
.symbol = pc.lot.symbol,
.lot = pc.lot,
.prepared_text = pc.text,
.row_style = pc.row_style,
}) catch continue;
}
}
}
// Cash section (filtered by account when filter is active)
if (pf.hasType(.cash)) {
// When filtered, only show cash lots matching the account
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(state, lot.account)) {
cash_lots.append(app.allocator, lot) catch continue;
}
}
if (cash_lots.items.len > 0) {
state.rows.append(app.allocator, .{
.kind = .section_header,
.symbol = "Cash",
}) catch {};
for (cash_lots.items) |lot| {
state.rows.append(app.allocator, .{
.kind = .cash_row,
.symbol = lot.account orelse "Unknown",
.lot = lot,
}) catch continue;
}
}
} else {
// Unfiltered: show total + expandable per-account rows
state.rows.append(app.allocator, .{
.kind = .section_header,
.symbol = "Cash",
}) catch {};
state.rows.append(app.allocator, .{
.kind = .cash_total,
.symbol = "CASH",
}) catch {};
if (state.cash_expanded) {
for (pf.lots) |lot| {
if (lot.security_type == .cash) {
state.rows.append(app.allocator, .{
.kind = .cash_row,
.symbol = lot.account orelse "Unknown",
.lot = lot,
}) catch continue;
}
}
}
}
}
// Illiquid assets section (hidden when account filter is active)
if (state.account_filter == null) {
if (pf.hasType(.illiquid)) {
state.rows.append(app.allocator, .{
.kind = .section_header,
.symbol = "Illiquid Assets",
}) catch {};
state.rows.append(app.allocator, .{
.kind = .illiquid_total,
.symbol = "ILLIQUID",
}) catch {};
if (state.illiquid_expanded) {
for (pf.lots) |lot| {
if (lot.security_type == .illiquid) {
state.rows.append(app.allocator, .{
.kind = .illiquid_row,
.symbol = lot.symbol,
.lot = lot,
}) catch continue;
}
}
}
}
}
}
}
/// Set or clear the account filter on portfolio.State. Owns
/// the filter string via allocator (dup on set, free on
/// clear/replace) and recomputes `filtered_positions` from
/// `app.portfolio.file` so subsequent renders don't have to
/// re-iterate lots. Pass `null` to clear.
pub fn setAccountFilter(state: *State, app: *App, name: ?[]const u8) void {
if (state.account_filter) |old| app.allocator.free(old);
if (state.filtered_positions) |fp| app.allocator.free(fp);
state.filtered_positions = null;
if (name) |n| {
state.account_filter = app.allocator.dupe(u8, n) catch null;
if (app.portfolio.file) |pf| {
state.filtered_positions = pf.positionsForAccount(app.today, app.allocator, n) catch null;
}
} else {
state.account_filter = null;
}
}
/// 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(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;
// Collect distinct account names from portfolio lots
var seen = std.StringHashMap(void).init(app.allocator);
defer seen.deinit();
var lot_accounts = std.ArrayList([]const u8).empty;
defer lot_accounts.deinit(app.allocator);
for (pf.lots) |lot| {
if (lot.account) |acct| {
if (acct.len > 0 and !seen.contains(acct)) {
seen.put(acct, {}) catch continue;
lot_accounts.append(app.allocator, acct) catch continue;
}
}
}
app.ensureAccountMap();
// Phase 1: add accounts in accounts.srf order (if available)
if (app.portfolio.account_map) |am| {
for (am.entries) |entry| {
if (seen.contains(entry.account)) {
state.account_list.append(app.allocator, entry.account) catch continue;
state.account_numbers.append(app.allocator, entry.account_number) catch continue;
}
}
}
// Phase 2: add accounts not in accounts.srf, sorted alphabetically
var extras = std.ArrayList([]const u8).empty;
defer extras.deinit(app.allocator);
for (lot_accounts.items) |acct| {
var found = false;
for (state.account_list.items) |existing| {
if (std.mem.eql(u8, acct, existing)) {
found = true;
break;
}
}
if (!found) extras.append(app.allocator, acct) catch continue;
}
std.mem.sort([]const u8, extras.items, {}, struct {
fn lessThan(_: void, a: []const u8, b: []const u8) bool {
return std.mem.lessThan(u8, a, b);
}
}.lessThan);
for (extras.items) |acct| {
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(state, app);
// If the current filter no longer exists in the new list, clear it
if (state.account_filter) |af| {
var found = false;
for (state.account_list.items) |acct| {
if (std.mem.eql(u8, acct, af)) {
found = true;
break;
}
}
if (!found) setAccountFilter(state, app, null);
}
}
const shortcut_key_order = "1234567890bcdefhimnoptuvwxyz";
fn assignShortcutKeys(state: *State, app: *App) void {
state.account_shortcut_keys.clearRetainingCapacity();
var key_idx: usize = 0;
for (0..state.account_list.items.len) |_| {
if (key_idx < shortcut_key_order.len) {
state.account_shortcut_keys.append(app.allocator, shortcut_key_order[key_idx]) catch continue;
key_idx += 1;
} else {
state.account_shortcut_keys.append(app.allocator, 0) catch continue;
}
}
}
/// Recompute filtered_positions when portfolio or account filter changes.
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;
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.
/// Returns true if `account` matches the active account filter.
/// When no filter is active, returns true (all accounts pass).
/// When an account is null but a filter is active, returns false.
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(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;
}
return false;
}
/// Account-filtered view of an allocation. When a position spans multiple accounts,
/// this holds the values for only the lots matching the active account filter.
const FilteredAlloc = struct {
shares: f64,
cost_basis: f64,
market_value: f64,
unrealized_gain_loss: f64,
};
/// Compute account-filtered values for an allocation.
/// For single-account positions (or no filter), returns the allocation's own values.
/// 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(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 = state.filtered_positions orelse return .{
.shares = 0,
.cost_basis = 0,
.market_value = 0,
.unrealized_gain_loss = 0,
};
// Sum across all filtered positions matching this symbol.
// For rolled-up allocations, the raw ticker price is used with each
// position's own price_ratio to compute correct per-position values.
var total_shares: f64 = 0;
var total_cost: f64 = 0;
var total_mv: f64 = 0;
var found = false;
for (fps) |pos| {
if (std.mem.eql(u8, pos.symbol, a.symbol) or std.mem.eql(u8, pos.lot_symbol, a.symbol)) {
found = true;
total_shares += pos.shares * pos.price_ratio; // normalize to base units
total_cost += pos.total_cost;
total_mv += pos.shares * a.current_price * pos.price_ratio;
}
}
if (!found) return .{
.shares = 0,
.cost_basis = 0,
.market_value = 0,
.unrealized_gain_loss = 0,
};
return .{
.shares = total_shares,
.cost_basis = total_cost,
.market_value = total_mv,
.unrealized_gain_loss = total_mv - total_cost,
};
}
/// Totals for the filtered account view (stocks + cash + CDs + options).
const FilteredTotals = struct {
value: f64,
cost: f64,
};
/// Compute total value and cost across all asset types for the active account filter.
/// Returns {0, 0} if no filter is active.
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(state, a)) {
const fa = filteredAllocValues(state, a);
value += fa.market_value;
cost += fa.cost_basis;
}
}
}
if (app.portfolio.file) |pf| {
const ns = pf.nonStockValueForAccount(app.today, af);
value += ns;
cost += ns;
}
return .{ .value = value, .cost = cost };
}
// ── Rendering ─────────────────────────────────────────────────
pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
// Modal sub-state takes over the content surface entirely.
// Picker overlay replaces the portfolio table while open.
if (state.modal != .none) {
try drawAccountPicker(state, app, arena, buf, width, height);
return;
}
const th = app.theme;
if (app.portfolio.file == null and app.watchlist == null) {
try drawWelcomeScreen(app, arena, buf, width, height);
return;
}
var lines: std.ArrayList(StyledLine) = .empty;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
if (app.portfolio.summary) |s| {
if (state.account_filter) |af| {
// Filtered mode: compute account-specific totals
const ft = computeFilteredTotals(state, app);
const filtered_value = ft.value;
const filtered_cost = ft.cost;
const filtered_gl = filtered_value - filtered_cost;
const filtered_return = if (filtered_cost > 0) (filtered_gl / filtered_cost) else @as(f64, 0);
// Account name line
const acct_text = try std.fmt.allocPrint(arena, " Account: {s}", .{af});
try lines.append(arena, .{ .text = acct_text, .style = th.headerStyle() });
const gl_abs = if (filtered_gl >= 0) filtered_gl else -filtered_gl;
const summary_text = try std.fmt.allocPrint(arena, " Value: {f} Cost: {f} Gain/Loss: {s}{f} ({d:.1}%)", .{
Money.from(filtered_value),
Money.from(filtered_cost),
if (filtered_gl >= 0) @as([]const u8, "+") else @as([]const u8, "-"),
Money.from(gl_abs),
filtered_return * 100.0,
});
const summary_style = if (filtered_gl >= 0) th.positiveStyle() else th.negativeStyle();
try lines.append(arena, .{ .text = summary_text, .style = summary_style });
if (app.portfolio.latest_quote_date) |d| {
const asof_text = try std.fmt.allocPrint(arena, " (as of close on {f})", .{d});
try lines.append(arena, .{ .text = asof_text, .style = th.mutedStyle() });
}
// No historical snapshots or net worth when filtered
} else {
// Unfiltered mode: use portfolio_summary totals directly
const gl_abs = if (s.unrealized_gain_loss >= 0) s.unrealized_gain_loss else -s.unrealized_gain_loss;
const summary_text = try std.fmt.allocPrint(arena, " Value: {f} Cost: {f} Gain/Loss: {s}{f} ({d:.1}%)", .{
Money.from(s.total_value),
Money.from(s.total_cost),
if (s.unrealized_gain_loss >= 0) @as([]const u8, "+") else @as([]const u8, "-"),
Money.from(gl_abs),
s.unrealized_return * 100.0,
});
const summary_style = if (s.unrealized_gain_loss >= 0) th.positiveStyle() else th.negativeStyle();
try lines.append(arena, .{ .text = summary_text, .style = summary_style });
// "as of" date indicator
if (app.portfolio.latest_quote_date) |d| {
const asof_text = try std.fmt.allocPrint(arena, " (as of close on {f})", .{d});
try lines.append(arena, .{ .text = asof_text, .style = th.mutedStyle() });
}
// Net Worth line (only if portfolio has illiquid assets)
if (app.portfolio.file) |pf| {
if (pf.hasType(.illiquid)) {
const illiquid_total = pf.totalIlliquid(app.today);
const net_worth = zfin.valuation.netWorth(app.today, pf, s);
const nw_text = try std.fmt.allocPrint(arena, " Net Worth: {f} (Liquid: {f} Illiquid: {f})", .{
Money.from(net_worth),
Money.from(s.total_value),
Money.from(illiquid_total),
});
try lines.append(arena, .{ .text = nw_text, .style = th.headerStyle() });
}
}
// Historical portfolio value snapshots
if (app.portfolio.historical_snapshots) |snapshots| {
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
var hist_parts: [6][]const u8 = undefined;
for (zfin.valuation.HistoricalPeriod.all, 0..) |period, pi| {
const snap = snapshots[pi];
var hbuf: [16]u8 = undefined;
const change_str = fmt.fmtHistoricalChange(&hbuf, snap.position_count, snap.changePct());
hist_parts[pi] = try std.fmt.allocPrint(arena, "{s}: {s}", .{ period.label(), change_str });
}
const hist_text = try std.fmt.allocPrint(arena, " Historical: {s} {s} {s} {s} {s} {s}", .{
hist_parts[0], hist_parts[1], hist_parts[2], hist_parts[3], hist_parts[4], hist_parts[5],
});
try lines.append(arena, .{ .text = hist_text, .style = th.mutedStyle() });
}
}
} else if (app.portfolio.file != null) {
try lines.append(arena, .{ .text = " No cached prices. Run 'zfin perf <SYMBOL>' for each holding.", .style = th.mutedStyle() });
} else {
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
}
// Empty line before header
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// 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", fmt.sym_col_width, true, if (sf == .symbol) si else null);
const shr_hdr = colLabel(&shr_hdr_buf, "Shares", 8, false, if (sf == .shares) si else null);
const avg_hdr = colLabel(&avg_hdr_buf, "Avg Cost", 10, false, if (sf == .avg_cost) si else null);
const prc_hdr = colLabel(&prc_hdr_buf, "Price", 10, false, if (sf == .price) si else null);
const mv_hdr = colLabel(&mv_hdr_buf, "Market Value", 16, false, if (sf == .market_value) si else null);
const gl_hdr = colLabel(&gl_hdr_buf, "Gain/Loss", 14, false, if (sf == .gain_loss) si else null);
const wt_hdr = colLabel(&wt_hdr_buf, "Weight", 8, false, if (sf == .weight) si else null);
const acct_ind: []const u8 = if (sf == .account) si else "";
const hdr = try std.fmt.allocPrint(arena, " {s} {s} {s} {s} {s} {s} {s} {s:>13} {s}{s}", .{
sym_hdr, shr_hdr, avg_hdr, prc_hdr, mv_hdr, gl_hdr, wt_hdr, "Date", acct_ind, "Account",
});
try lines.append(arena, .{ .text = hdr, .style = th.headerStyle() });
// Track header line count for mouse click mapping (after all header lines)
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 (state.account_filter != null)
computeFilteredTotals(state, app).value
else
0;
// Data rows
for (state.rows.items, 0..) |row, ri| {
const lines_before = lines.items.len;
const is_cursor = ri == state.cursor;
const is_active_sym = std.mem.eql(u8, row.symbol, app.symbol);
switch (row.kind) {
.position => {
if (app.portfolio.summary) |s| {
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(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 < 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);
var gl_val_buf: [24]u8 = undefined;
const gl_abs = if (display_gl >= 0) display_gl else -display_gl;
const gl_money = std.fmt.bufPrint(&gl_val_buf, "{f}", .{Money.from(gl_abs)}) catch "$?";
var pnl_buf: [20]u8 = undefined;
const pnl_str = if (display_gl >= 0)
std.fmt.bufPrint(&pnl_buf, "+{s}", .{gl_money}) catch "?"
else
std.fmt.bufPrint(&pnl_buf, "-{s}", .{gl_money}) catch "?";
var mv_buf: [24]u8 = undefined;
const mv_str = std.fmt.bufPrint(&mv_buf, "{f}", .{Money.from(display_mv)}) catch "$?";
var cost_buf2: [24]u8 = undefined;
const cost_str = std.fmt.bufPrint(&cost_buf2, "{f}", .{Money.from(display_avg_cost)}) catch "$?";
var price_buf2: [24]u8 = undefined;
const price_str = std.fmt.bufPrint(&price_buf2, "{f}", .{Money.from(a.current_price)}) catch "$?";
// Date + ST/LT: show for single-lot, blank for multi-lot
var pos_date_buf: [10]u8 = undefined;
var date_col: []const u8 = "";
var acct_col: []const u8 = "";
if (!is_multi) {
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(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;
acct_col = lot.account orelse "";
break;
}
}
}
}
} else if (state.account_filter) |af| {
acct_col = af;
} else {
acct_col = a.account;
}
const display_weight = if (state.account_filter != null and filtered_total_for_weight > 0)
(display_mv / filtered_total_for_weight)
else
a.weight;
const text = try std.fmt.allocPrint(arena, "{s}{s}" ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} {s:>10} {s:>16} {s:>14} {d:>7.1}% {s:>13} {s}", .{
arrow, star, a.display_symbol, display_shares, cost_str, price_str, mv_str, pnl_str, display_weight * 100.0, date_col, acct_col,
});
// base: neutral text for main cols, green/red only for gain/loss col
// Manual-price positions use warning color to indicate stale/estimated price
const base_style = if (is_cursor) th.selectStyle() else if (a.is_manual_price) th.warningStyle() else th.contentStyle();
const gl_style = if (is_cursor) th.selectStyle() else if (pnl_pct >= 0) th.positiveStyle() else th.negativeStyle();
try lines.append(arena, .{
.text = text,
.style = base_style,
.alt_style = gl_style,
.alt_start = gl_col_start,
.alt_end = gl_col_start + 14,
});
}
}
},
.lot => {
if (row.lot) |lot| {
var date_buf: [10]u8 = undefined;
const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{lot.open_date}) catch "????-??-??";
// Compute lot gain/loss and market value if we have a price
var lot_gl_str: []const u8 = "";
var lot_mv_str: []const u8 = "";
var lot_positive = true;
if (app.portfolio.summary) |s| {
if (row.pos_idx < s.allocations.len) {
const price = s.allocations[row.pos_idx].current_price;
const use_price = lot.close_price orelse price;
const gl = lot.shares * (use_price - lot.open_price);
lot_positive = gl >= 0;
lot_gl_str = try std.fmt.allocPrint(arena, "{s}{f}", .{
if (gl >= 0) @as([]const u8, "+") else @as([]const u8, "-"),
Money.from(if (gl >= 0) gl else -gl),
});
lot_mv_str = try std.fmt.allocPrint(arena, "{f}", .{Money.from(lot.shares * use_price)});
}
}
var price_str2: [24]u8 = undefined;
const lot_price_str = std.fmt.bufPrint(&price_str2, "{f}", .{Money.from(lot.open_price)}) catch "$?";
const status_str: []const u8 = if (lot.isOpen(app.today)) "open" else "closed";
const indicator = fmt.capitalGainsIndicator(app.today, lot.open_date);
const lot_date_col = try std.fmt.allocPrint(arena, "{s} {s}", .{ date_str, indicator });
const acct_col: []const u8 = lot.account orelse "";
const text = try std.fmt.allocPrint(arena, " " ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13} {s}", .{
status_str, lot.shares, lot_price_str, "", lot_mv_str, lot_gl_str, "", lot_date_col, acct_col,
});
const base_style = if (is_cursor) th.selectStyle() else th.mutedStyle();
const gl_col_style = if (is_cursor) th.selectStyle() else if (lot_positive) th.positiveStyle() else th.negativeStyle();
try lines.append(arena, .{
.text = text,
.style = base_style,
.alt_style = gl_col_style,
.alt_start = gl_col_start,
.alt_end = gl_col_start + 14,
});
}
},
.watchlist => {
var price_str3: [16]u8 = undefined;
const ps: []const u8 = if (app.portfolio.watchlist_prices) |wp|
(if (wp.get(row.symbol)) |p| (std.fmt.bufPrint(&price_str3, "{f}", .{Money.from(p)}) catch "$?") else "--")
else
"--";
const star2: []const u8 = if (is_active_sym) "* " else " ";
const text = try std.fmt.allocPrint(arena, " {s}" ++ fmt.sym_col_spec ++ " {s:>8} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13}", .{
star2, row.symbol, "--", "--", ps, "--", "--", "watch", "",
});
const row_style = if (is_cursor) th.selectStyle() else th.contentStyle();
try lines.append(arena, .{ .text = text, .style = row_style });
},
.section_header => {
// Blank line before section header
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
const hdr_text = try std.fmt.allocPrint(arena, " {s}", .{row.symbol});
const hdr_style = if (is_cursor) th.selectStyle() else th.headerStyle();
try lines.append(arena, .{ .text = hdr_text, .style = hdr_style });
// Add column headers for each section type
if (std.mem.eql(u8, row.symbol, "Options")) {
const col_hdr = try std.fmt.allocPrint(arena, views.OptionsLayout.header, views.OptionsLayout.header_labels);
try lines.append(arena, .{ .text = col_hdr, .style = th.mutedStyle() });
} else if (std.mem.eql(u8, row.symbol, "Certificates of Deposit")) {
const col_hdr = try std.fmt.allocPrint(arena, views.CDsLayout.header, views.CDsLayout.header_labels);
try lines.append(arena, .{ .text = col_hdr, .style = th.mutedStyle() });
}
},
.option_row => {
if (row.prepared_text) |text| {
const row_style2 = if (is_cursor) th.selectStyle() else mapIntent(th, row.row_style);
const prem_style = if (is_cursor) th.selectStyle() else mapIntent(th, row.premium_style);
try lines.append(arena, .{
.text = text,
.style = row_style2,
.alt_style = prem_style,
.alt_start = row.premium_col_start,
.alt_end = row.premium_col_start + 14,
});
}
},
.cd_row => {
if (row.prepared_text) |text| {
const row_style3 = if (is_cursor) th.selectStyle() else mapIntent(th, row.row_style);
try lines.append(arena, .{ .text = text, .style = row_style3 });
}
},
.cash_total => {
if (app.portfolio.file) |pf| {
const total_cash = pf.totalCash(app.today);
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),
});
const row_style4 = if (is_cursor) th.selectStyle() else th.contentStyle();
try lines.append(arena, .{ .text = text, .style = row_style4 });
}
},
.cash_row => {
if (row.lot) |lot| {
var cash_row_buf: [160]u8 = undefined;
const row_text = fmt.fmtCashRow(&cash_row_buf, row.symbol, lot.shares, lot.note);
const text = try std.fmt.allocPrint(arena, " {s}", .{row_text});
const row_style5 = if (is_cursor) th.selectStyle() else th.mutedStyle();
try lines.append(arena, .{ .text = text, .style = row_style5 });
}
},
.illiquid_total => {
if (app.portfolio.file) |pf| {
const total_illiquid = pf.totalIlliquid(app.today);
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),
});
const row_style6 = if (is_cursor) th.selectStyle() else th.contentStyle();
try lines.append(arena, .{ .text = text, .style = row_style6 });
}
},
.illiquid_row => {
if (row.lot) |lot| {
var illiquid_row_buf: [160]u8 = undefined;
const row_text = fmt.fmtIlliquidRow(&illiquid_row_buf, row.symbol, lot.shares, lot.note);
const text = try std.fmt.allocPrint(arena, " {s}", .{row_text});
const row_style7 = if (is_cursor) th.selectStyle() else th.mutedStyle();
try lines.append(arena, .{ .text = text, .style = row_style7 });
}
},
.drip_summary => {
var drip_buf: [128]u8 = undefined;
const drip_text = fmt.fmtDripSummary(&drip_buf, if (row.drip_is_lt) "LT" else "ST", .{
.lot_count = row.drip_lot_count,
.shares = row.drip_shares,
.cost = row.drip_shares * row.drip_avg_cost,
.first_date = row.drip_date_first,
.last_date = row.drip_date_last,
});
const text = try std.fmt.allocPrint(arena, " {s}", .{drip_text});
const drip_style = if (is_cursor) th.selectStyle() else th.mutedStyle();
try lines.append(arena, .{ .text = text, .style = drip_style });
},
}
// 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 - state.header_lines;
if (map_idx < state.line_to_row.len) {
state.line_to_row[map_idx] = ri;
}
}
state.line_count = lines_after - state.header_lines;
}
// Render
const start = @min(app.scroll_offset, if (lines.items.len > 0) lines.items.len - 1 else 0);
try app.drawStyledContent(arena, buf, width, height, lines.items[start..]);
}
fn drawWelcomeScreen(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
// Resolve key bindings dynamically so the welcome screen reflects
// the user's actual keymap (defaults or overridden via keys.srf).
// Each `keysForGlobal` returns at least one key — global default
// bindings always exist (verified by the comptime conflict
// validator + tests).
const keys: WelcomeKeys = .{
.symbol_input = (try app.keysForGlobal(arena, .symbol_input))[0],
.select_next = (try app.keysForGlobal(arena, .select_next))[0],
.select_prev = (try app.keysForGlobal(arena, .select_prev))[0],
.prev_tab = (try app.keysForGlobal(arena, .prev_tab))[0],
.next_tab = (try app.keysForGlobal(arena, .next_tab))[0],
.help = (try app.keysForGlobal(arena, .help))[0],
.quit = (try app.keysForGlobal(arena, .quit))[0],
.tab_1 = (try app.keysForGlobal(arena, .tab_1))[0],
.tab_5 = (try app.keysForGlobal(arena, .tab_5))[0],
.expand_collapse = (try app.keysForTabAction(arena, "portfolio", "expand_collapse"))[0],
.select_symbol = (try app.keysForTabAction(arena, "portfolio", "select_symbol"))[0],
};
const lines = try buildWelcomeScreenLines(arena, app.theme, keys);
try app.drawStyledContent(arena, buf, width, height, lines);
}
/// Pre-resolved key bindings used by `buildWelcomeScreenLines`. All
/// fields are formatted key strings (e.g. `"j"`, `"ctrl+f"`) sourced
/// from the live keymap; the renderer doesn't know about the keymap.
pub const WelcomeKeys = struct {
symbol_input: []const u8,
select_next: []const u8,
select_prev: []const u8,
prev_tab: []const u8,
next_tab: []const u8,
help: []const u8,
quit: []const u8,
tab_1: []const u8,
tab_5: []const u8,
expand_collapse: []const u8,
select_symbol: []const u8,
};
/// Build the styled lines for the welcome screen shown when no
/// portfolio is loaded. Pure function over (arena, theme, keys);
/// no App access. Easy to unit-test by passing fixture keys.
pub fn buildWelcomeScreenLines(
arena: std.mem.Allocator,
th: theme.Theme,
keys: WelcomeKeys,
) ![]const StyledLine {
var lines: std.ArrayListUnmanaged(StyledLine) = .empty;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " zfin", .style = th.headerStyle() });
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " No portfolio loaded.", .style = th.mutedStyle() });
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " Getting started:", .style = th.contentStyle() });
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {s:<10} Enter a stock symbol (e.g. AAPL, VTI)", .{keys.symbol_input}),
.style = th.contentStyle(),
});
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " Portfolio mode:", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " zfin -p portfolio.srf Load a portfolio file", .style = th.mutedStyle() });
try lines.append(arena, .{ .text = " portfolio.srf Auto-loaded from cwd if present", .style = th.mutedStyle() });
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " Navigation:", .style = th.contentStyle() });
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {s} / {s} Previous / next tab", .{ keys.prev_tab, keys.next_tab }),
.style = th.mutedStyle(),
});
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {s} / {s} Select next / prev item", .{ keys.select_next, keys.select_prev }),
.style = th.mutedStyle(),
});
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {s:<10} Expand position lots", .{keys.expand_collapse}),
.style = th.mutedStyle(),
});
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {s:<10} Select symbol for other tabs", .{keys.select_symbol}),
.style = th.mutedStyle(),
});
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {s}-{s} Jump to tab", .{ keys.tab_1, keys.tab_5 }),
.style = th.mutedStyle(),
});
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {s:<10} Full help", .{keys.help}),
.style = th.mutedStyle(),
});
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {s:<10} Quit", .{keys.quit}),
.style = th.mutedStyle(),
});
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " Sample portfolio.srf:", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " symbol::VTI,shares::100,open_date::2024-01-15,open_price::220.50", .style = th.mutedStyle() });
try lines.append(arena, .{ .text = " symbol::AAPL,shares::50,open_date::2024-03-01,open_price::170.00", .style = th.mutedStyle() });
return lines.toOwnedSlice(arena);
}
/// Reload portfolio file from disk without re-fetching prices.
/// Uses cached candle data to recompute summary.
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.
state.account_list.clearRetainingCapacity();
// Re-read the portfolio file
if (app.portfolio.file) |*pf| pf.deinit();
app.portfolio.file = null;
if (app.portfolio_path) |path| {
const file_data = std.Io.Dir.cwd().readFileAlloc(app.io, path, app.allocator, .limited(10 * 1024 * 1024)) catch {
app.setStatus("Error reading portfolio file");
return;
};
defer app.allocator.free(file_data);
if (zfin.cache.deserializePortfolio(app.allocator, file_data)) |pf| {
app.portfolio.file = pf;
} else |_| {
app.setStatus("Error parsing portfolio file");
return;
}
} else {
app.setStatus("No portfolio file to reload");
return;
}
// Reload watchlist file too (if separate)
tui.freeWatchlist(app.allocator, app.watchlist);
app.watchlist = null;
if (app.watchlist_path) |path| {
app.watchlist = tui.loadWatchlist(app.io, app.allocator, path);
}
// Recompute summary using cached prices (no network)
app.freePortfolioSummary();
state.expanded = @splat(false);
state.cash_expanded = false;
state.illiquid_expanded = false;
state.cursor = 0;
app.scroll_offset = 0;
state.rows.clearRetainingCapacity();
const pf = app.portfolio.file orelse return;
const positions = pf.positions(app.today, app.allocator) catch {
app.setStatus("Error computing positions");
return;
};
defer app.allocator.free(positions);
var prices = std.StringHashMap(f64).init(app.allocator);
defer prices.deinit();
const syms = pf.stockSymbols(app.allocator) catch {
app.setStatus("Error getting symbols");
return;
};
defer app.allocator.free(syms);
var latest_date: ?zfin.Date = null;
var missing: usize = 0;
for (syms) |sym| {
// Cache only — no network
const candles_slice = app.svc.getCachedCandles(sym);
if (candles_slice) |cs| {
defer cs.deinit();
if (cs.data.len > 0) {
prices.put(sym, cs.data[cs.data.len - 1].close) catch {};
const d = cs.data[cs.data.len - 1].date;
if (latest_date == null or d.days > latest_date.?.days) latest_date = d;
}
} else {
missing += 1;
}
}
app.portfolio.latest_quote_date = latest_date;
// Build portfolio summary, candle map, and historical snapshots from cache
var pf_data = cli.buildPortfolioData(app.allocator, pf, positions, syms, &prices, app.svc, app.today) catch |err| switch (err) {
error.NoAllocations => {
app.setStatus("No cached prices available");
return;
},
error.SummaryFailed => {
app.setStatus("Error computing portfolio summary");
return;
},
else => {
app.setStatus("Error building portfolio data");
return;
},
};
app.portfolio.summary = pf_data.summary;
app.portfolio.historical_snapshots = pf_data.snapshots;
{
var it = pf_data.candle_map.valueIterator();
while (it.next()) |v| app.allocator.free(v.*);
pf_data.candle_map.deinit();
}
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);
app.states.analysis.result = null;
app.states.analysis.loaded = false;
// Note: `analysis_tab.tab.isDisabled` derives availability from
// `app.portfolio.file`, so we don't need to clear a `disabled`
// flag here — it's recomputed at every read.
// If currently on the analysis tab, eagerly recompute so the user
// doesn't see an error message before switching away and back.
if (app.active_tab == .analysis) {
analysis_tab.tab.activate(&app.states.analysis, app) catch {};
}
// Invalidate projections data — projections.srf may have changed.
// Always drop the cached context so a stale render doesn't leak;
// re-fetch only if the user is actively looking at projections.
// (When not active, the next `activate` lazily re-fetches.)
if (app.active_tab == .projections) {
projections_tab.tab.reload(&app.states.projections, app) catch {};
} else {
projections_tab.freeLoaded(&app.states.projections, app);
app.states.projections.loaded = false;
}
if (missing > 0) {
var warn_buf: [128]u8 = undefined;
const warn_msg = std.fmt.bufPrint(&warn_buf, "Reloaded. {d} symbols missing cached prices", .{missing}) catch "Reloaded (some prices missing)";
app.setStatus(warn_msg);
} else {
app.setStatus("Portfolio reloaded from disk");
}
}
// ── Account picker ────────────────────────────────────────────
/// Number of header lines in the account picker before the list items start.
/// Used for mouse click hit-testing.
pub const account_picker_header_lines: usize = 3;
/// Draw the account picker overlay (replaces portfolio content).
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;
const is_searching = state.modal == .account_search;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " Filter by Account", .style = th.headerStyle() });
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// Build a set of search-highlighted indices for fast lookup
var search_highlight = std.AutoHashMap(usize, void).init(arena);
var search_cursor_idx: ?usize = null;
if (is_searching and state.account_search_matches.items.len > 0) {
for (state.account_search_matches.items, 0..) |match_idx, si| {
search_highlight.put(match_idx, {}) catch {};
if (si == state.account_search_cursor) search_cursor_idx = match_idx;
}
}
// Item 0 = "All accounts" (clears filter)
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)
else
i == state.account_picker_cursor;
const marker: []const u8 = if (is_selected) " > " else " ";
if (i == 0) {
const text = try std.fmt.allocPrint(arena, "{s}A: All accounts", .{marker});
const style = if (is_selected) th.selectStyle() else th.contentStyle();
const dimmed = is_searching and state.account_search_len > 0;
try lines.append(arena, .{ .text = text, .style = if (dimmed) th.mutedStyle() else style });
} else {
const acct_idx = i - 1;
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)
try std.fmt.allocPrint(arena, "{s}{c}: {s} ({s})", .{ marker, shortcut, label, num })
else
try std.fmt.allocPrint(arena, "{s} {s} ({s})", .{ marker, label, num }))
else if (shortcut != 0)
try std.fmt.allocPrint(arena, "{s}{c}: {s}", .{ marker, shortcut, label })
else
try std.fmt.allocPrint(arena, "{s} {s}", .{ marker, label });
var style = if (is_selected) th.selectStyle() else th.contentStyle();
if (is_searching and state.account_search_len > 0) {
if (search_highlight.contains(acct_idx)) {
if (!is_selected) style = th.headerStyle();
} else {
style = th.mutedStyle();
}
}
try lines.append(arena, .{ .text = text, .style = style });
}
}
// Search prompt at the bottom
if (is_searching) {
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
const query = state.account_search_buf[0..state.account_search_len];
const match_count = state.account_search_matches.items.len;
const prompt = if (query.len > 0)
try std.fmt.allocPrint(arena, " /{s} ({d} match{s})", .{
query,
match_count,
if (match_count != 1) @as([]const u8, "es") else "",
})
else
try std.fmt.allocPrint(arena, " /", .{});
try lines.append(arena, .{ .text = prompt, .style = th.headerStyle() });
} else {
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " /: search j/k: navigate Enter: select Esc: cancel", .style = th.mutedStyle() });
}
// Scroll so cursor is visible
const effective_cursor = if (is_searching)
(if (search_cursor_idx) |sci| sci + 1 else 0)
else
state.account_picker_cursor;
const cursor_line = effective_cursor + account_picker_header_lines;
var start: usize = 0;
if (cursor_line >= height) {
start = cursor_line - height + 2;
}
start = @min(start, if (lines.items.len > 0) lines.items.len - 1 else 0);
try app.drawStyledContent(arena, buf, width, height, lines.items[start..]);
}
/// Mouse handling for the account-picker modal. Wheel scrolls
/// the cursor; left-click on a list item selects + applies +
/// dismisses. Returns `true` for any consumed event (including
/// non-content clicks) so the picker swallows everything in its
/// mode and the rest of the app's mouse pipeline doesn't see it.
pub fn handleAccountPickerMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool {
const total_items = state.account_list.items.len + 1;
switch (mouse.button) {
.wheel_up => {
if (app.shouldDebounceWheel()) return true;
if (state.account_picker_cursor > 0)
state.account_picker_cursor -= 1;
return true;
},
.wheel_down => {
if (app.shouldDebounceWheel()) return true;
if (total_items > 0 and state.account_picker_cursor < total_items - 1)
state.account_picker_cursor += 1;
return true;
},
.left => {
if (mouse.type != .press) return true;
// Map click row to picker item index. The picker is
// drawn at content origin (row 1), but the existing
// hit-test uses raw `mouse.row` against
// `account_picker_header_lines` — preserve that
// behavior. (Drift in the picker layout would shift
// the off-by-one; not changing it here.)
const content_row = @as(usize, @intCast(mouse.row));
if (content_row >= account_picker_header_lines) {
const item_idx = content_row - account_picker_header_lines;
if (item_idx < total_items) {
state.account_picker_cursor = item_idx;
applyAccountPickerSelection(state, app);
return true;
}
}
return true;
},
else => return true,
}
}
/// Key handler for the account-picker modal. Called from
/// `handleKey` (the framework pre-empt hook) when
/// `state.modal == .account_picker`.
///
/// Modal keys are hardcoded universal conventions (Enter/Esc/q
/// to dismiss, '/' to enter search, 'A' for "All accounts",
/// shortcut keys for instant select). Navigation (j/k/g/G) uses
/// the global keymap so user-rebound nav keys work consistently.
///
/// Returns `true` for any consumed key — including unrecognized
/// keys, which are intentionally swallowed so they can't
/// "leak" through to global keymap matching while the modal is
/// open.
fn handleAccountPickerKey(state: *State, app: *App, key: vaxis.Key) bool {
const total_items = state.account_list.items.len + 1; // +1 for "All accounts"
if (key.codepoint == vaxis.Key.escape or key.codepoint == 'q') {
state.modal = .none;
return true;
}
if (key.codepoint == vaxis.Key.enter) {
applyAccountPickerSelection(state, app);
return true;
}
// '/' enters search mode
if (key.matches('/', .{})) {
state.modal = .account_search;
state.account_search_len = 0;
updateAccountSearchMatches(state, app);
return true;
}
// 'A' selects "All accounts" instantly
if (key.matches('A', .{})) {
state.account_picker_cursor = 0;
applyAccountPickerSelection(state, app);
return true;
}
// 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 (state.account_shortcut_keys.items, 0..) |shortcut, i| {
if (shortcut == ch) {
state.account_picker_cursor = i + 1; // +1 for "All accounts" at 0
applyAccountPickerSelection(state, app);
return true;
}
}
}
// Navigation via keymap
if (app.keymap.matchAction(key)) |action| {
switch (action) {
.select_next => {
if (total_items > 0 and state.account_picker_cursor < total_items - 1)
state.account_picker_cursor += 1;
},
.select_prev => {
if (state.account_picker_cursor > 0)
state.account_picker_cursor -= 1;
},
.scroll_top => {
state.account_picker_cursor = 0;
},
.scroll_bottom => {
if (total_items > 0)
state.account_picker_cursor = total_items - 1;
},
else => {},
}
}
// Swallow unrecognized keys — modal contract.
return true;
}
/// Key handler for the account-search modal (`/` within picker).
/// Called from `handleKey` when `state.modal == .account_search`.
/// Modal keys are hardcoded.
///
/// Returns `true` for any consumed key (always — modal swallows
/// everything).
fn handleAccountSearchKey(state: *State, app: *App, key: vaxis.Key) bool {
// Escape: cancel search, return to picker
if (key.codepoint == vaxis.Key.escape) {
state.modal = .account_picker;
state.account_search_len = 0;
return true;
}
// Enter: select the first match (or current search cursor)
if (key.codepoint == vaxis.Key.enter) {
if (state.account_search_matches.items.len > 0) {
const match_idx = state.account_search_matches.items[state.account_search_cursor];
state.account_picker_cursor = match_idx + 1; // +1 for "All accounts"
}
state.account_search_len = 0;
applyAccountPickerSelection(state, app);
return true;
}
// Ctrl+N / Ctrl+P or arrow keys to cycle through matches
if (key.matches('n', .{ .ctrl = true }) or key.codepoint == vaxis.Key.down) {
if (state.account_search_matches.items.len > 0 and
state.account_search_cursor < state.account_search_matches.items.len - 1)
state.account_search_cursor += 1;
return true;
}
if (key.matches('p', .{ .ctrl = true }) or key.codepoint == vaxis.Key.up) {
if (state.account_search_cursor > 0)
state.account_search_cursor -= 1;
return true;
}
// Backspace
if (key.codepoint == vaxis.Key.backspace) {
if (state.account_search_len > 0) {
state.account_search_len -= 1;
updateAccountSearchMatches(state, app);
}
return true;
}
// Ctrl+U: clear search
if (key.matches('u', .{ .ctrl = true })) {
state.account_search_len = 0;
updateAccountSearchMatches(state, app);
return true;
}
// Printable ASCII
if (key.codepoint < std.math.maxInt(u7) and std.ascii.isPrint(@intCast(key.codepoint)) and state.account_search_len < state.account_search_buf.len) {
state.account_search_buf[state.account_search_len] = @intCast(key.codepoint);
state.account_search_len += 1;
updateAccountSearchMatches(state, app);
return true;
}
// Swallow unrecognized keys — modal contract.
return true;
}
/// Update search match indices based on current search string.
/// Searches account name AND account number (so users can find
/// "401k" by typing the number from accounts.srf).
fn updateAccountSearchMatches(state: *State, app: *App) void {
state.account_search_matches.clearRetainingCapacity();
const query = state.account_search_buf[0..state.account_search_len];
if (query.len == 0) return;
var lower_query: [64]u8 = undefined;
for (query, 0..) |c, i| lower_query[i] = std.ascii.toLower(c);
const lq = lower_query[0..query.len];
for (state.account_list.items, 0..) |acct, i| {
if (containsLower(acct, lq)) {
state.account_search_matches.append(app.allocator, i) catch continue;
} else if (i < state.account_numbers.items.len) {
if (state.account_numbers.items[i]) |num| {
if (containsLower(num, lq)) {
state.account_search_matches.append(app.allocator, i) catch continue;
}
}
}
}
if (state.account_search_cursor >= state.account_search_matches.items.len) {
state.account_search_cursor = if (state.account_search_matches.items.len > 0)
state.account_search_matches.items.len - 1
else
0;
}
}
/// Case-insensitive substring search. Linear scan; haystacks here
/// are short (account names) so a smarter algorithm wouldn't pay
/// off.
fn containsLower(haystack: []const u8, needle_lower: []const u8) bool {
if (needle_lower.len == 0) return true;
if (haystack.len < needle_lower.len) return false;
const end = haystack.len - needle_lower.len + 1;
for (0..end) |start| {
var matched = true;
for (0..needle_lower.len) |j| {
if (std.ascii.toLower(haystack[start + j]) != needle_lower[j]) {
matched = false;
break;
}
}
if (matched) return true;
}
return false;
}
/// Apply the current account picker selection and return to
/// normal mode. Selection 0 = "All accounts" (clears filter);
/// 1..N = nth account in `account_list`.
fn applyAccountPickerSelection(state: *State, app: *App) void {
if (state.account_picker_cursor == 0) {
// "All accounts" — clear filter
setAccountFilter(state, app, null);
} else {
const idx = state.account_picker_cursor - 1;
if (idx < state.account_list.items.len) {
setAccountFilter(state, app, state.account_list.items[idx]);
}
}
state.modal = .none;
state.cursor = 0;
app.scroll_offset = 0;
rebuildPortfolioRows(state, app);
if (state.account_filter) |af| {
var tmp_buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&tmp_buf, "Filtered: {s}", .{af}) catch "Filtered";
app.setStatus(msg);
} else {
app.setStatus("Filter cleared: showing all accounts");
}
}
// ── Tests ─────────────────────────────────────────────────────
const testing = std.testing;
test "PortfolioSortField next/prev" {
// next from first field
try testing.expectEqual(PortfolioSortField.shares, PortfolioSortField.symbol.next().?);
// next from last field returns null
try testing.expectEqual(@as(?PortfolioSortField, null), PortfolioSortField.account.next());
// prev from first returns null
try testing.expectEqual(@as(?PortfolioSortField, null), PortfolioSortField.symbol.prev());
// prev from last
try testing.expectEqual(PortfolioSortField.weight, PortfolioSortField.account.prev().?);
}
test "PortfolioSortField label" {
try testing.expectEqualStrings("Symbol", PortfolioSortField.symbol.label());
try testing.expectEqualStrings("Market Value", PortfolioSortField.market_value.label());
}
test "SortDirection flip and indicator" {
try testing.expectEqual(SortDirection.desc, SortDirection.asc.flip());
try testing.expectEqual(SortDirection.asc, SortDirection.desc.flip());
try testing.expectEqualStrings("\xe2\x96\xb2", SortDirection.asc.indicator()); // ▲
try testing.expectEqualStrings("\xe2\x96\xbc", SortDirection.desc.indicator()); // ▼
}
test "buildWelcomeScreenLines: includes resolved keys in expected slots" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const keys: WelcomeKeys = .{
.symbol_input = "/",
.select_next = "j",
.select_prev = "k",
.prev_tab = "h",
.next_tab = "l",
.help = "?",
.quit = "q",
.tab_1 = "1",
.tab_5 = "5",
.expand_collapse = "enter",
.select_symbol = "s",
};
const lines = try buildWelcomeScreenLines(arena, theme.default_theme, keys);
// Concatenate all line text for substring assertions.
var all: std.ArrayListUnmanaged(u8) = .empty;
for (lines) |l| {
try all.appendSlice(arena, l.text);
try all.append(arena, '\n');
}
const text = all.items;
// Header + body sections present.
try testing.expect(std.mem.indexOf(u8, text, "zfin") != null);
try testing.expect(std.mem.indexOf(u8, text, "No portfolio loaded.") != null);
try testing.expect(std.mem.indexOf(u8, text, "Getting started:") != null);
try testing.expect(std.mem.indexOf(u8, text, "Portfolio mode:") != null);
try testing.expect(std.mem.indexOf(u8, text, "Navigation:") != null);
try testing.expect(std.mem.indexOf(u8, text, "Sample portfolio.srf:") != null);
// All resolved keys appear in their respective rows. Format
// strings use `{s:<10}` (ten-char field) and ` ` (two-space)
// gap before the description, so the gap between key and
// description = (10 - keylen) padding + 2 separator.
try testing.expect(std.mem.indexOf(u8, text, "/ Enter a stock symbol") != null);
try testing.expect(std.mem.indexOf(u8, text, "h / l Previous / next tab") != null);
try testing.expect(std.mem.indexOf(u8, text, "j / k Select next / prev item") != null);
try testing.expect(std.mem.indexOf(u8, text, "enter Expand position lots") != null);
try testing.expect(std.mem.indexOf(u8, text, "s Select symbol for other tabs") != null);
try testing.expect(std.mem.indexOf(u8, text, "1-5 Jump to tab") != null);
try testing.expect(std.mem.indexOf(u8, text, "? Full help") != null);
try testing.expect(std.mem.indexOf(u8, text, "q Quit") != null);
}
test "buildWelcomeScreenLines: respects rebound keys" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
// User-rebound keys: arbitrary substitutions.
const keys: WelcomeKeys = .{
.symbol_input = "ctrl+s",
.select_next = "down",
.select_prev = "up",
.prev_tab = "shift+tab",
.next_tab = "tab",
.help = "F1",
.quit = "ctrl+q",
.tab_1 = "f1",
.tab_5 = "f5",
.expand_collapse = "space",
.select_symbol = "x",
};
const lines = try buildWelcomeScreenLines(arena, theme.default_theme, keys);
var all: std.ArrayListUnmanaged(u8) = .empty;
for (lines) |l| {
try all.appendSlice(arena, l.text);
try all.append(arena, '\n');
}
const text = all.items;
// Verify every rebound key is rendered.
try testing.expect(std.mem.indexOf(u8, text, "ctrl+s") != null);
try testing.expect(std.mem.indexOf(u8, text, "shift+tab / tab") != null);
try testing.expect(std.mem.indexOf(u8, text, "down / up") != null);
try testing.expect(std.mem.indexOf(u8, text, "space") != null);
try testing.expect(std.mem.indexOf(u8, text, "x Select symbol") != null);
try testing.expect(std.mem.indexOf(u8, text, "f1-f5") != null);
try testing.expect(std.mem.indexOf(u8, text, "F1 Full help") != null);
try testing.expect(std.mem.indexOf(u8, text, "ctrl+q Quit") != null);
// No default keys leaked through (sanity).
try testing.expect(std.mem.indexOf(u8, text, " / Enter a stock symbol") == null);
try testing.expect(std.mem.indexOf(u8, text, "h / l") == null);
try testing.expect(std.mem.indexOf(u8, text, "j / k") == null);
}
test "matchesAccountFilter: no filter = pass-through" {
const state: State = .{};
try testing.expect(matchesAccountFilter(&state, "Brokerage"));
try testing.expect(matchesAccountFilter(&state, null));
}
test "matchesAccountFilter: with filter, only matching account passes" {
const state: State = .{ .account_filter = "Brokerage" };
try testing.expect(matchesAccountFilter(&state, "Brokerage"));
try testing.expect(!matchesAccountFilter(&state, "IRA"));
}
test "matchesAccountFilter: with filter, null account fails" {
const state: State = .{ .account_filter = "Brokerage" };
try testing.expect(!matchesAccountFilter(&state, null));
}
test "ensureCursorVisible: cursor above viewport scrolls up" {
var state: State = .{ .cursor = 5, .header_lines = 2 };
var scroll: usize = 20;
ensureCursorVisible(&state, &scroll, 10);
// cursor_row = 5 + 2 = 7, which is < 20, so scroll_offset = 7.
try testing.expectEqual(@as(usize, 7), scroll);
}
test "ensureCursorVisible: cursor below viewport scrolls down" {
var state: State = .{ .cursor = 50, .header_lines = 2 };
var scroll: usize = 0;
ensureCursorVisible(&state, &scroll, 10);
// cursor_row = 52, scroll_offset = 0, vis = 10, 52 >= 0+10
// → scroll = 52 - 10 + 1 = 43.
try testing.expectEqual(@as(usize, 43), scroll);
}
test "ensureCursorVisible: cursor inside viewport leaves scroll alone" {
var state: State = .{ .cursor = 5, .header_lines = 2 };
var scroll: usize = 0;
ensureCursorVisible(&state, &scroll, 20);
// cursor_row = 7, in [0, 20), no change.
try testing.expectEqual(@as(usize, 0), scroll);
}
test "ensureCursorVisible: zero visible height is a no-op for the lower bound" {
var state: State = .{ .cursor = 0, .header_lines = 0 };
var scroll: usize = 5;
ensureCursorVisible(&state, &scroll, 0);
// cursor_row = 0 < 5 → scroll = 0. Lower-bound branch fires.
try testing.expectEqual(@as(usize, 0), scroll);
}