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 ' 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); }