diff --git a/src/tui.zig b/src/tui.zig index 08acdb5..4a6a9df 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -289,6 +289,7 @@ pub const TabStates = struct { options: options_tab.State = .{}, history: history_tab.State = .{}, projections: projections_tab.State = .{}, + portfolio: portfolio_tab.State = .{}, }; /// Comptime registry of all tab modules conforming to the @@ -327,6 +328,7 @@ const tab_modules = .{ .options = options_tab, .history = history_tab, .projections = projections_tab, + .portfolio = portfolio_tab, }; comptime { @@ -550,31 +552,17 @@ pub const App = struct { input_buf: [16]u8 = undefined, input_len: usize = 0, - // Portfolio navigation - cursor: usize = 0, // selected row in portfolio view - expanded: [64]bool = @splat(false), // which positions are expanded - cash_expanded: bool = false, // whether cash section is expanded to show per-account - illiquid_expanded: bool = false, // whether illiquid section is expanded to show per-asset - portfolio_rows: std.ArrayList(PortfolioRow) = .empty, - prepared_options: ?views.Options = null, - prepared_cds: ?views.CDs = null, - portfolio_header_lines: usize = 0, // number of styled lines before data rows - portfolio_line_to_row: [256]usize = @splat(0), // maps styled line index -> portfolio_rows index - portfolio_line_count: usize = 0, // total styled lines in portfolio view - portfolio_sort_field: PortfolioSortField = .symbol, // current sort column - portfolio_sort_dir: SortDirection = .asc, // current sort direction - prefetched_prices: ?std.StringHashMap(f64) = null, // prices loaded before TUI starts (with stderr progress) + // Portfolio tab state lives in `self.states.portfolio` (see TabStates). - // Account filter state - account_filter: ?[]const u8 = null, // active account filter (owned copy; null = all accounts) - filtered_positions: ?[]zfin.Position = null, // positions for filtered account (from positionsForAccount) - account_list: std.ArrayList([]const u8) = .empty, // distinct accounts from portfolio lots (ordered by accounts.srf) - account_numbers: std.ArrayList(?[]const u8) = .empty, // account_number from accounts.srf (parallel to account_list) - account_shortcut_keys: std.ArrayList(u8) = .empty, // auto-assigned shortcut key per account (parallel to account_list) + // Account picker / search modal state. The portfolio tab opens + // the picker via the `account_filter` action, but the picker + // itself is a global UI mode (mode = .account_picker) that + // operates on portfolio state via `self.states.portfolio`. + // Search-mode is mode = .account_search. account_picker_cursor: usize = 0, // cursor position in picker (0 = "All accounts") account_search_buf: [64]u8 = undefined, account_search_len: usize = 0, - account_search_matches: std.ArrayList(usize) = .empty, // indices into account_list matching search + account_search_matches: std.ArrayList(usize) = .empty, // indices into states.portfolio.account_list matching search account_search_cursor: usize = 0, // cursor within search_matches // History tab state lives in `self.states.history` (see TabStates). @@ -634,7 +622,7 @@ pub const App = struct { fn handleMouse(self: *App, ctx: *vaxis.vxfw.EventContext, mouse: vaxis.Mouse) void { // Account picker mouse handling if (self.mode == .account_picker) { - const total_items = self.account_list.items.len + 1; + const total_items = self.states.portfolio.account_list.items.len + 1; switch (mouse.button) { .wheel_up => { if (self.shouldDebounceWheel()) return; @@ -695,56 +683,6 @@ pub const App = struct { col += lbl_len; } } - // Portfolio tab: click header to sort, click row to expand/collapse - // Portfolio tab: click header to sort, click row to expand/collapse - if (self.active_tab == .portfolio and mouse.row > 0) { - const content_row = @as(usize, @intCast(mouse.row)) + self.scroll_offset; - // Click on column header row -> sort by that column - if (self.portfolio_header_lines > 0 and content_row == self.portfolio_header_lines - 1) { - const col = @as(usize, @intCast(mouse.col)); - const new_field: ?PortfolioSortField = - if (col < portfolio_tab.col_end_symbol) - .symbol - else if (col < portfolio_tab.col_end_shares) - .shares - else if (col < portfolio_tab.col_end_avg_cost) - .avg_cost - else if (col < portfolio_tab.col_end_price) - .price - else if (col < portfolio_tab.col_end_market_value) - .market_value - else if (col < portfolio_tab.col_end_gain_loss) - .gain_loss - else if (col < portfolio_tab.col_end_weight) - .weight - else if (col < portfolio_tab.col_end_date) - null // Date (not sortable) - else - .account; - if (new_field) |nf| { - if (nf == self.portfolio_sort_field) { - self.portfolio_sort_dir = self.portfolio_sort_dir.flip(); - } else { - self.portfolio_sort_field = nf; - self.portfolio_sort_dir = if (nf == .symbol or nf == .account) .asc else .desc; - } - self.sortPortfolioAllocations(); - self.rebuildPortfolioRows(); - return ctx.consumeAndRedraw(); - } - } - if (content_row >= self.portfolio_header_lines and self.portfolio_rows.items.len > 0) { - const line_idx = content_row - self.portfolio_header_lines; - if (line_idx < self.portfolio_line_count and line_idx < self.portfolio_line_to_row.len) { - const row_idx = self.portfolio_line_to_row[line_idx]; - if (row_idx < self.portfolio_rows.items.len) { - self.cursor = row_idx; - self.toggleExpand(); - return ctx.consumeAndRedraw(); - } - } - } - } // Framework dispatch: ask the active tab's `handleMouse` // (when defined) if it wants to consume this click. // @@ -1024,7 +962,7 @@ pub const App = struct { /// Handles keypresses in account_picker mode. fn handleAccountPickerKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void { - const total_items = self.account_list.items.len + 1; // +1 for "All accounts" + const total_items = self.states.portfolio.account_list.items.len + 1; // +1 for "All accounts" if (key.codepoint == vaxis.Key.escape or key.codepoint == 'q') { self.mode = .normal; @@ -1054,7 +992,7 @@ pub const App = struct { // Check shortcut keys for instant selection if (key.codepoint < std.math.maxInt(u7) and key.matches(key.codepoint, .{})) { const ch: u8 = @intCast(key.codepoint); - for (self.account_shortcut_keys.items, 0..) |shortcut, i| { + for (self.states.portfolio.account_shortcut_keys.items, 0..) |shortcut, i| { if (shortcut == ch) { self.account_picker_cursor = i + 1; // +1 for "All accounts" at 0 self.applyAccountPickerSelection(); @@ -1157,11 +1095,11 @@ pub const App = struct { for (query, 0..) |c, i| lower_query[i] = std.ascii.toLower(c); const lq = lower_query[0..query.len]; - for (self.account_list.items, 0..) |acct, i| { + for (self.states.portfolio.account_list.items, 0..) |acct, i| { if (containsLower(acct, lq)) { self.account_search_matches.append(self.allocator, i) catch continue; - } else if (i < self.account_numbers.items.len) { - if (self.account_numbers.items[i]) |num| { + } else if (i < self.states.portfolio.account_numbers.items.len) { + if (self.states.portfolio.account_numbers.items[i]) |num| { if (containsLower(num, lq)) { self.account_search_matches.append(self.allocator, i) catch continue; } @@ -1201,16 +1139,16 @@ pub const App = struct { self.setAccountFilter(null); } else { const idx = self.account_picker_cursor - 1; - if (idx < self.account_list.items.len) { - self.setAccountFilter(self.account_list.items[idx]); + if (idx < self.states.portfolio.account_list.items.len) { + self.setAccountFilter(self.states.portfolio.account_list.items[idx]); } } self.mode = .normal; - self.cursor = 0; + self.states.portfolio.cursor = 0; self.scroll_offset = 0; - portfolio_tab.rebuildPortfolioRows(self); + portfolio_tab.rebuildPortfolioRows(&self.states.portfolio, self); - if (self.account_filter) |af| { + if (self.states.portfolio.account_filter) |af| { var tmp_buf: [256]u8 = undefined; const msg = std.fmt.bufPrint(&tmp_buf, "Filtered: {s}", .{af}) catch "Filtered"; self.setStatus(msg); @@ -1228,17 +1166,17 @@ pub const App = struct { /// Set or clear the account filter. Owns the string via allocator. pub fn setAccountFilter(self: *App, name: ?[]const u8) void { - if (self.account_filter) |old| self.allocator.free(old); - if (self.filtered_positions) |fp| self.allocator.free(fp); - self.filtered_positions = null; + if (self.states.portfolio.account_filter) |old| self.allocator.free(old); + if (self.states.portfolio.filtered_positions) |fp| self.allocator.free(fp); + self.states.portfolio.filtered_positions = null; if (name) |n| { - self.account_filter = self.allocator.dupe(u8, n) catch null; + self.states.portfolio.account_filter = self.allocator.dupe(u8, n) catch null; if (self.portfolio.file) |pf| { - self.filtered_positions = pf.positionsForAccount(self.today, self.allocator, n) catch null; + self.states.portfolio.filtered_positions = pf.positionsForAccount(self.today, self.allocator, n) catch null; } } else { - self.account_filter = null; + self.states.portfolio.account_filter = null; } } @@ -1263,11 +1201,11 @@ pub const App = struct { // Escape: clear account filter on portfolio tab, clear as-of // on projections tab, no-op otherwise. if (key.codepoint == vaxis.Key.escape) { - if (self.active_tab == .portfolio and self.account_filter != null) { + if (self.active_tab == .portfolio and self.states.portfolio.account_filter != null) { self.setAccountFilter(null); - self.cursor = 0; + self.states.portfolio.cursor = 0; self.scroll_offset = 0; - portfolio_tab.rebuildPortfolioRows(self); + portfolio_tab.rebuildPortfolioRows(&self.states.portfolio, self); self.setStatus("Filter cleared: showing all accounts"); return ctx.consumeAndRedraw(); } @@ -1294,13 +1232,8 @@ pub const App = struct { }, .select_symbol => { // 's' selects the current portfolio row's symbol as the active symbol - if (self.active_tab == .portfolio and self.portfolio_rows.items.len > 0 and self.cursor < self.portfolio_rows.items.len) { - const row = self.portfolio_rows.items[self.cursor]; - self.setActiveSymbol(row.symbol); - // Format into a separate buffer to avoid aliasing with status_msg - var tmp_buf: [256]u8 = undefined; - const msg = std.fmt.bufPrint(&tmp_buf, "Active: {s}", .{row.symbol}) catch "Active"; - self.setStatus(msg); + if (self.active_tab == .portfolio) { + portfolio_tab.tab.handleAction(&self.states.portfolio, self, portfolio_tab.Action.select_symbol); return ctx.consumeAndRedraw(); } }, @@ -1344,7 +1277,7 @@ pub const App = struct { }, .expand_collapse => { if (self.active_tab == .portfolio) { - self.toggleExpand(); + portfolio_tab.tab.handleAction(&self.states.portfolio, self, portfolio_tab.Action.expand_collapse); return ctx.consumeAndRedraw(); } else if (self.active_tab == .options) { options_tab.tab.handleAction(&self.states.options, self, options_tab.Action.expand_collapse); @@ -1377,14 +1310,11 @@ pub const App = struct { }, .scroll_top => { self.scroll_offset = 0; - if (self.active_tab == .portfolio) self.cursor = 0; self.dispatchVoid("onScroll", .{tab_framework.ScrollEdge.top}); return ctx.consumeAndRedraw(); }, .scroll_bottom => { self.scroll_offset = std.math.maxInt(usize) / 2; // clamped during draw...divide by 2 to avoid overflow if arithmetic is done - if (self.active_tab == .portfolio and self.portfolio_rows.items.len > 0) - self.cursor = self.portfolio_rows.items.len - 1; self.dispatchVoid("onScroll", .{tab_framework.ScrollEdge.bottom}); return ctx.consumeAndRedraw(); }, @@ -1446,23 +1376,13 @@ pub const App = struct { }, .sort_col_next => { if (self.active_tab == .portfolio) { - if (self.portfolio_sort_field.next()) |new_field| { - self.portfolio_sort_field = new_field; - self.portfolio_sort_dir = if (new_field == .symbol or new_field == .account) .asc else .desc; - self.sortPortfolioAllocations(); - self.rebuildPortfolioRows(); - } + portfolio_tab.tab.handleAction(&self.states.portfolio, self, portfolio_tab.Action.sort_col_next); return ctx.consumeAndRedraw(); } }, .sort_col_prev => { if (self.active_tab == .portfolio) { - if (self.portfolio_sort_field.prev()) |new_field| { - self.portfolio_sort_field = new_field; - self.portfolio_sort_dir = if (new_field == .symbol or new_field == .account) .asc else .desc; - self.sortPortfolioAllocations(); - self.rebuildPortfolioRows(); - } + portfolio_tab.tab.handleAction(&self.states.portfolio, self, portfolio_tab.Action.sort_col_prev); return ctx.consumeAndRedraw(); } }, @@ -1477,9 +1397,7 @@ pub const App = struct { // select_symbol). The action name stays `sort_reverse` // because portfolio was the first consumer. if (self.active_tab == .portfolio) { - self.portfolio_sort_dir = self.portfolio_sort_dir.flip(); - self.sortPortfolioAllocations(); - self.rebuildPortfolioRows(); + portfolio_tab.tab.handleAction(&self.states.portfolio, self, portfolio_tab.Action.sort_reverse); return ctx.consumeAndRedraw(); } if (self.active_tab == .projections) { @@ -1488,18 +1406,8 @@ pub const App = struct { } }, .account_filter => { - if (self.active_tab == .portfolio and self.portfolio.file != null) { - self.mode = .account_picker; - // Position cursor on the currently-active filter (or 0 for "All") - self.account_picker_cursor = 0; - if (self.account_filter) |af| { - for (self.account_list.items, 0..) |acct, ai| { - if (std.mem.eql(u8, acct, af)) { - self.account_picker_cursor = ai + 1; // +1 because 0 = "All accounts" - break; - } - } - } + if (self.active_tab == .portfolio) { + portfolio_tab.tab.handleAction(&self.states.portfolio, self, portfolio_tab.Action.open_account_picker); return ctx.consumeAndRedraw(); } }, @@ -1576,17 +1484,8 @@ pub const App = struct { /// For other tabs (or cursor-bearing tabs with empty rows), /// adjusts scroll_offset by |n|. fn moveBy(self: *App, n: isize) void { - // Unmigrated cursor-bearing tab (portfolio). - // Its cursor state still lives on App; once migrated, this - // branch goes into an onCursorMove hook like options/history. - if (self.active_tab == .portfolio) { - if (self.shouldDebounceWheel()) return; - stepCursor(&self.cursor, self.portfolio_rows.items.len, n); - self.ensureCursorVisible(); - return; - } - // Migrated cursor-bearing tabs (options, history). The - // hook returns false when it has no rows, so we fall + // Migrated cursor-bearing tabs (portfolio, options, history). + // The hook returns false when it has no rows, so we fall // through to scroll. Debounce applies to the cursor-move // path only — preserving legacy behavior where wheel // events on non-cursor views scroll without debounce. @@ -1604,56 +1503,6 @@ pub const App = struct { } } - fn stepCursor(cursor: *usize, row_count: usize, direction: isize) void { - if (direction > 0) { - if (row_count > 0 and cursor.* < row_count - 1) - cursor.* += 1; - } else { - if (cursor.* > 0) cursor.* -= 1; - } - } - - fn ensureCursorVisible(self: *App) void { - const cursor_row = self.cursor + self.portfolio_header_lines; - if (cursor_row < self.scroll_offset) { - self.scroll_offset = cursor_row; - } - const vis: usize = self.visible_height; - if (cursor_row >= self.scroll_offset + vis) { - self.scroll_offset = cursor_row - vis + 1; - } - } - - fn toggleExpand(self: *App) void { - if (self.portfolio_rows.items.len == 0) return; - if (self.cursor >= self.portfolio_rows.items.len) return; - const row = self.portfolio_rows.items[self.cursor]; - switch (row.kind) { - .position => { - // Single-lot positions don't expand - if (row.lot_count <= 1) return; - if (row.pos_idx < self.expanded.len) { - self.expanded[row.pos_idx] = !self.expanded[row.pos_idx]; - self.rebuildPortfolioRows(); - } - }, - .lot, .option_row, .cd_row, .cash_row, .illiquid_row, .section_header, .drip_summary => {}, - .cash_total => { - self.cash_expanded = !self.cash_expanded; - self.rebuildPortfolioRows(); - }, - .illiquid_total => { - self.illiquid_expanded = !self.illiquid_expanded; - self.rebuildPortfolioRows(); - }, - .watchlist => { - self.setActiveSymbol(row.symbol); - self.active_tab = .quote; - self.loadTabData(); - }, - } - } - pub fn setActiveSymbol(self: *App, sym: []const u8) void { const len = @min(sym.len, self.symbol_buf.len); @memcpy(self.symbol_buf[0..len], sym[0..len]); @@ -1743,10 +1592,10 @@ pub const App = struct { } } - fn loadTabData(self: *App) void { + pub fn loadTabData(self: *App) void { switch (self.active_tab) { .portfolio => { - if (!self.portfolio.loaded) self.loadPortfolioData(); + portfolio_tab.tab.activate(&self.states.portfolio, self) catch {}; }, .quote, .performance => { if (self.symbol.len == 0) return; @@ -1773,15 +1622,7 @@ pub const App = struct { } pub fn loadPortfolioData(self: *App) void { - portfolio_tab.loadPortfolioData(self); - } - - fn sortPortfolioAllocations(self: *App) void { - portfolio_tab.sortPortfolioAllocations(self); - } - - fn rebuildPortfolioRows(self: *App) void { - portfolio_tab.rebuildPortfolioRows(self); + portfolio_tab.loadPortfolioData(&self.states.portfolio, self); } pub fn setStatus(self: *App, msg: []const u8) void { @@ -1800,25 +1641,12 @@ pub const App = struct { self.portfolio.summary = null; } - pub fn freePreparedSections(self: *App) void { - if (self.prepared_options) |*opts| opts.deinit(); - self.prepared_options = null; - if (self.prepared_cds) |*cds| cds.deinit(); - self.prepared_cds = null; - } - fn deinitData(self: *App) void { self.symbol_data.deinit(self.allocator); earnings_tab.tab.deinit(&self.states.earnings, self); options_tab.tab.deinit(&self.states.options, self); - self.freePreparedSections(); - self.portfolio_rows.deinit(self.allocator); - self.account_list.deinit(self.allocator); - self.account_numbers.deinit(self.allocator); - self.account_shortcut_keys.deinit(self.allocator); + portfolio_tab.tab.deinit(&self.states.portfolio, self); self.account_search_matches.deinit(self.allocator); - if (self.account_filter) |af| self.allocator.free(af); - if (self.filtered_positions) |fp| self.allocator.free(fp); analysis_tab.tab.deinit(&self.states.analysis, self); self.portfolio.deinit(self.allocator); history_tab.tab.deinit(&self.states.history, self); @@ -1827,7 +1655,7 @@ pub const App = struct { } fn reloadPortfolioFile(self: *App) void { - portfolio_tab.reloadPortfolioFile(self); + portfolio_tab.reloadPortfolioFile(&self.states.portfolio, self); } // ── Drawing ────────────────────────────────────────────────── @@ -1910,9 +1738,9 @@ pub const App = struct { fn isSymbolSelected(self: *App) bool { // Symbol is "selected" if it matches a portfolio/watchlist row the user explicitly selected with 's' if (self.active_tab != .portfolio) return false; - if (self.portfolio_rows.items.len == 0) return false; - if (self.cursor >= self.portfolio_rows.items.len) return false; - return std.mem.eql(u8, self.portfolio_rows.items[self.cursor].symbol, self.symbol); + if (self.states.portfolio.rows.items.len == 0) return false; + if (self.states.portfolio.cursor >= self.states.portfolio.rows.items.len) return false; + return std.mem.eql(u8, self.states.portfolio.rows.items[self.states.portfolio.cursor].symbol, self.symbol); } fn drawContent(self: *App, ctx: vaxis.vxfw.DrawContext, width: u16, height: u16) !vaxis.vxfw.Surface { @@ -1925,7 +1753,7 @@ pub const App = struct { if (self.mode == .help) { try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildHelpStyledLines(ctx.arena)); } else if (self.mode == .account_picker or self.mode == .account_search) { - try portfolio_tab.drawAccountPicker(self, ctx.arena, buf, width, height); + try portfolio_tab.drawAccountPicker(&self.states.portfolio, self, ctx.arena, buf, width, height); } else { switch (self.active_tab) { .portfolio => try self.drawPortfolioContent(ctx.arena, buf, width, height), @@ -2049,8 +1877,8 @@ pub const App = struct { const status_style = t.statusStyle(); @memset(buf, .{ .char = .{ .grapheme = " " }, .style = status_style }); // Show account filter indicator when active, appended to status message - if (self.account_filter != null and self.active_tab == .portfolio) { - const af = self.account_filter.?; + if (self.states.portfolio.account_filter != null and self.active_tab == .portfolio) { + const af = self.states.portfolio.account_filter.?; const msg = self.getStatus(); const filter_text = std.fmt.allocPrint(ctx.arena, "{s} [Account: {s}]", .{ msg, af }) catch msg; for (0..@min(filter_text.len, width)) |i| { @@ -2070,7 +1898,7 @@ pub const App = struct { // ── Portfolio content ───────────────────────────────────────── fn drawPortfolioContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { - return portfolio_tab.drawContent(self, arena, buf, width, height); + return portfolio_tab.drawContent(&self.states.portfolio, self, arena, buf, width, height); } // ── Options content (with cursor/scroll) ───────────────────── @@ -2516,7 +2344,7 @@ pub fn run( false, // force_refresh true, // color ); - app_inst.prefetched_prices = load_result.prices; + app_inst.states.portfolio.prefetched_prices = load_result.prices; } // Eagerly compute PortfolioData so the history-tab's live @@ -2525,7 +2353,7 @@ pub fn run( // first. Cheap (pure compute + cache reads) once prices are // already in hand. if (app_inst.portfolio.file != null) { - portfolio_tab.loadPortfolioData(app_inst); + portfolio_tab.loadPortfolioData(&app_inst.states.portfolio, app_inst); } } diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index a2fd623..9b90965 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -9,11 +9,13 @@ const theme = @import("theme.zig"); const tui = @import("../tui.zig"); const projections_tab = @import("projections_tab.zig"); const analysis_tab = @import("analysis_tab.zig"); +const framework = @import("tab_framework.zig"); const App = tui.App; const StyledLine = tui.StyledLine; const PortfolioRow = tui.PortfolioRow; const PortfolioSortField = tui.PortfolioSortField; +const SortDirection = tui.SortDirection; const colLabel = tui.colLabel; const glyph = tui.glyph; @@ -23,6 +25,343 @@ const glyph = tui.glyph; const prefix_cols: usize = 4; const sw: usize = fmt.sym_col_width; +// ── Tab-local action enum ───────────────────────────────────── +// +// Portfolio tab keybinds (today routed through legacy global +// `keybinds.Action` variants — these tab-local declarations +// become authoritative when scoped keymaps land): +// - Enter : expand/collapse position at cursor +// - `s` / space : select cursor row's symbol as active +// - `S` / `;` : sort columns (next/prev) +// - `o` : reverse sort direction +// - `a` : open account picker + +pub const Action = enum { + /// Expand/collapse the position at the cursor row. + expand_collapse, + /// Move the cursor to the next sortable column. + sort_col_next, + /// Move the cursor to the previous sortable column. + sort_col_prev, + /// Flip the current sort direction (asc ↔ desc). + sort_reverse, + /// Open the account picker modal (mode = .account_picker). + /// No-op if no portfolio is loaded. + open_account_picker, + /// Select the cursor row's symbol as the currently-active + /// symbol for the per-symbol tabs (quote/perf/options/etc.). + select_symbol, +}; + +// ── Tab-private state ───────────────────────────────────────── + +pub const State = struct { + /// Selected row in the portfolio view. + cursor: usize = 0, + /// Per-position expansion flags. Indices align with + /// `app.portfolio.summary.allocations`. Fixed-size; positions + /// beyond index 64 are non-expandable in the UI. + expanded: [64]bool = @splat(false), + /// Whether the cash section is expanded to show per-account + /// balances. + cash_expanded: bool = false, + /// Whether the illiquid section is expanded to show + /// per-asset rows. + illiquid_expanded: bool = false, + /// Flat list of styled rows for the current view, rebuilt by + /// `rebuildPortfolioRows` whenever sort / filter / expansion + /// changes. Owned by State. + rows: std.ArrayList(PortfolioRow) = .empty, + /// Pre-computed Options section, when the portfolio holds + /// option positions. Owned by State. + prepared_options: ?views.Options = null, + /// Pre-computed CDs section, when the portfolio holds CDs. + /// Owned by State. + prepared_cds: ?views.CDs = null, + /// Number of styled lines before the first data row (header, + /// totals, etc.). Used by mouse hit-tests and cursor-visibility + /// math. + header_lines: usize = 0, + /// Maps styled-line index → row index in `rows`. Sized to a + /// fixed cap; `line_count` is the live extent. + line_to_row: [256]usize = @splat(0), + /// Total styled lines in the portfolio view (sum of header + /// + per-row lines). + line_count: usize = 0, + /// Current sort column. + sort_field: PortfolioSortField = .symbol, + /// Current sort direction (default: asc for symbol/account, + /// desc for numeric columns). + sort_dir: SortDirection = .asc, + /// Active account filter (owned copy; null = all accounts). + account_filter: ?[]const u8 = null, + /// Cached positions for the active account filter, computed + /// on filter change so subsequent renders don't re-iterate + /// the lots list. + filtered_positions: ?[]zfin.Position = null, + /// Distinct accounts from the portfolio's lots, ordered as + /// they appear in `accounts.srf` (or first-seen if no + /// accounts.srf). + account_list: std.ArrayList([]const u8) = .empty, + /// Account number from `accounts.srf` (parallel to + /// `account_list`). Null entries mean "no account number". + account_numbers: std.ArrayList(?[]const u8) = .empty, + /// Auto-assigned shortcut-key per account, parallel to + /// `account_list`. Used by the account picker modal. + account_shortcut_keys: std.ArrayList(u8) = .empty, + /// Prefetched prices populated before the TUI started (with + /// stderr progress). Consumed by the first `loadPortfolioData` + /// call to skip redundant network round-trips. Owned by State; + /// freed after first consumption. + prefetched_prices: ?std.StringHashMap(f64) = null, +}; + +// ── Tab framework contract ──────────────────────────────────── + +pub const tab = struct { + pub const ActionT = Action; + pub const StateT = State; + + pub const default_bindings: []const framework.TabBinding(Action) = &.{ + // These are dead until scoped keymaps land — the global + // keymap matches first. Declared per-tab so the help + // overlay and future scoped-dispatch can find them. + .{ .action = .expand_collapse, .key = .{ .codepoint = vaxis.Key.enter } }, + .{ .action = .sort_col_next, .key = .{ .codepoint = 'S' } }, + .{ .action = .sort_col_prev, .key = .{ .codepoint = ';' } }, + .{ .action = .sort_reverse, .key = .{ .codepoint = 'o' } }, + .{ .action = .open_account_picker, .key = .{ .codepoint = 'a' } }, + .{ .action = .select_symbol, .key = .{ .codepoint = 's' } }, + .{ .action = .select_symbol, .key = .{ .codepoint = vaxis.Key.space } }, + }; + + pub const action_labels = std.enums.EnumArray(Action, []const u8).init(.{ + .expand_collapse = "Expand/collapse position", + .sort_col_next = "Sort: next column", + .sort_col_prev = "Sort: previous column", + .sort_reverse = "Sort: reverse direction", + .open_account_picker = "Filter by account", + .select_symbol = "Select symbol", + }); + + pub const status_hints: []const Action = &.{ + .sort_col_next, + .sort_reverse, + .open_account_picker, + }; + + pub fn init(state: *State, app: *App) !void { + _ = app; + state.* = .{}; + } + + pub fn deinit(state: *State, app: *App) void { + state.rows.deinit(app.allocator); + if (state.prepared_options) |*opts| opts.deinit(); + if (state.prepared_cds) |*cds| cds.deinit(); + if (state.account_filter) |af| app.allocator.free(af); + if (state.filtered_positions) |fp| app.allocator.free(fp); + state.account_list.deinit(app.allocator); + state.account_numbers.deinit(app.allocator); + state.account_shortcut_keys.deinit(app.allocator); + state.* = .{}; + } + + pub fn activate(state: *State, app: *App) !void { + if (app.portfolio.loaded) return; + loadPortfolioData(state, app); + } + + pub const deactivate = framework.noopDeactivate(State); + + pub fn reload(state: *State, app: *App) !void { + reloadPortfolioFile(state, app); + } + + pub const tick = framework.noopTick(State); + + pub fn handleAction(state: *State, app: *App, action: Action) void { + switch (action) { + .expand_collapse => toggleExpandAtCursor(state, app), + .sort_col_next => { + if (state.sort_field.next()) |new_field| { + state.sort_field = new_field; + state.sort_dir = if (new_field == .symbol or new_field == .account) .asc else .desc; + sortPortfolioAllocations(state, app); + rebuildPortfolioRows(state, app); + } + }, + .sort_col_prev => { + if (state.sort_field.prev()) |new_field| { + state.sort_field = new_field; + state.sort_dir = if (new_field == .symbol or new_field == .account) .asc else .desc; + sortPortfolioAllocations(state, app); + rebuildPortfolioRows(state, app); + } + }, + .sort_reverse => { + state.sort_dir = state.sort_dir.flip(); + sortPortfolioAllocations(state, app); + rebuildPortfolioRows(state, app); + }, + .open_account_picker => { + if (app.portfolio.file == null) return; + app.mode = .account_picker; + // Position cursor on the currently-active filter (or 0 for "All") + app.account_picker_cursor = 0; + if (state.account_filter) |af| { + for (state.account_list.items, 0..) |acct, ai| { + if (std.mem.eql(u8, acct, af)) { + app.account_picker_cursor = ai + 1; // +1 because 0 = "All accounts" + break; + } + } + } + }, + .select_symbol => { + if (state.rows.items.len == 0) return; + if (state.cursor >= state.rows.items.len) return; + const row = state.rows.items[state.cursor]; + app.setActiveSymbol(row.symbol); + var tmp_buf: [256]u8 = undefined; + const msg = std.fmt.bufPrint(&tmp_buf, "Active: {s}", .{row.symbol}) catch "Active"; + app.setStatus(msg); + }, + } + } + + /// Portfolio is always enabled (the tab itself; data may be + /// empty if no portfolio file is loaded — that's a separate + /// concern handled by `drawWelcomeScreen`). + pub const isDisabled = framework.alwaysEnabled(); + + /// Sync the cursor to the new scroll extreme. + pub fn onScroll(state: *State, app: *App, where: framework.ScrollEdge) void { + _ = app; + switch (where) { + .top => state.cursor = 0, + .bottom => { + if (state.rows.items.len > 0) { + state.cursor = state.rows.items.len - 1; + } + }, + } + } + + /// Step the cursor by one row. Returns false when there are + /// no rows so the framework falls through to viewport scroll. + pub fn onCursorMove(state: *State, app: *App, delta: isize) bool { + if (state.rows.items.len == 0) return false; + if (delta > 0) { + if (state.cursor < state.rows.items.len - 1) state.cursor += 1; + } else { + if (state.cursor > 0) state.cursor -= 1; + } + ensureCursorVisible(state, &app.scroll_offset, app.visible_height); + return true; + } + + /// Mouse handling: clicks on the column-header row sort by + /// that column; clicks on a data row move the cursor and + /// toggle expand/collapse. Returns `true` if consumed. + pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool { + if (mouse.button != .left) return false; + if (mouse.type != .press) return false; + const content_row = @as(usize, @intCast(mouse.row)) + app.scroll_offset; + + // Click on the column-header row → sort by that column. + if (state.header_lines > 0 and content_row == state.header_lines - 1) { + const col = @as(usize, @intCast(mouse.col)); + const new_field: ?PortfolioSortField = + if (col < col_end_symbol) + .symbol + else if (col < col_end_shares) + .shares + else if (col < col_end_avg_cost) + .avg_cost + else if (col < col_end_price) + .price + else if (col < col_end_market_value) + .market_value + else if (col < col_end_gain_loss) + .gain_loss + else if (col < col_end_weight) + .weight + else if (col < col_end_date) + null // Date (not sortable) + else + .account; + if (new_field) |nf| { + if (nf == state.sort_field) { + state.sort_dir = state.sort_dir.flip(); + } else { + state.sort_field = nf; + state.sort_dir = if (nf == .symbol or nf == .account) .asc else .desc; + } + sortPortfolioAllocations(state, app); + rebuildPortfolioRows(state, app); + return true; + } + return false; + } + + // Click on a data row → move cursor + toggle expand. + if (content_row >= state.header_lines and state.rows.items.len > 0) { + const line_idx = content_row - state.header_lines; + if (line_idx < state.line_count and line_idx < state.line_to_row.len) { + const row_idx = state.line_to_row[line_idx]; + if (row_idx < state.rows.items.len) { + state.cursor = row_idx; + toggleExpandAtCursor(state, app); + return true; + } + } + } + return false; + } +}; + +fn ensureCursorVisible(state: *const State, scroll_offset: *usize, visible_height: usize) void { + const cursor_row = state.cursor + state.header_lines; + if (cursor_row < scroll_offset.*) { + scroll_offset.* = cursor_row; + } + const vis: usize = visible_height; + if (vis > 0 and cursor_row >= scroll_offset.* + vis) { + scroll_offset.* = cursor_row - vis + 1; + } +} + +fn toggleExpandAtCursor(state: *State, app: *App) void { + if (state.rows.items.len == 0) return; + if (state.cursor >= state.rows.items.len) return; + const row = state.rows.items[state.cursor]; + switch (row.kind) { + .position => { + // Single-lot positions don't expand + if (row.lot_count <= 1) return; + if (row.pos_idx < state.expanded.len) { + state.expanded[row.pos_idx] = !state.expanded[row.pos_idx]; + rebuildPortfolioRows(state, app); + } + }, + .lot, .option_row, .cd_row, .cash_row, .illiquid_row, .section_header, .drip_summary => {}, + .cash_total => { + state.cash_expanded = !state.cash_expanded; + rebuildPortfolioRows(state, app); + }, + .illiquid_total => { + state.illiquid_expanded = !state.illiquid_expanded; + rebuildPortfolioRows(state, app); + }, + .watchlist => { + app.setActiveSymbol(row.symbol); + app.active_tab = .quote; + app.loadTabData(); + }, + } +} + /// Cumulative column end positions for click-to-sort hit testing. pub const col_end_symbol: usize = prefix_cols + sw + 1; pub const col_end_shares: usize = col_end_symbol + 9; @@ -53,7 +392,7 @@ fn mapIntent(th: anytype, intent: fmt.StyleIntent) @import("vaxis").Style { /// On first call, uses prefetched_prices (populated before TUI started). /// On refresh, fetches live via svc.loadPrices. Tab switching skips this /// entirely because the portfolio_loaded guard in loadTabData() short-circuits. -pub fn loadPortfolioData(app: *App) void { +pub fn loadPortfolioData(state: *State, app: *App) void { app.portfolio.loaded = true; app.freePortfolioSummary(); @@ -81,7 +420,7 @@ pub fn loadPortfolioData(app: *App) void { var stale_count: usize = 0; var failed_syms: [8][]const u8 = undefined; - if (app.prefetched_prices) |*pp| { + if (state.prefetched_prices) |*pp| { // Use pre-fetched prices from before TUI started (first load only) // Move stock prices into the working map for (syms) |sym| { @@ -103,7 +442,7 @@ pub fn loadPortfolioData(app: *App) void { } pp.deinit(); - app.prefetched_prices = null; + state.prefetched_prices = null; } else { // Live fetch (refresh path) — fetch watchlist first, then stock prices if (app.portfolio.watchlist_prices) |*wp| wp.clearRetainingCapacity() else { @@ -195,10 +534,10 @@ pub fn loadPortfolioData(app: *App) void { pf_data.candle_map.deinit(); } - sortPortfolioAllocations(app); - buildAccountList(app); - recomputeFilteredPositions(app); - rebuildPortfolioRows(app); + sortPortfolioAllocations(state, app); + buildAccountList(state, app); + recomputeFilteredPositions(state, app); + rebuildPortfolioRows(state, app); const summary = pf_data.summary; if (app.symbol.len == 0 and summary.allocations.len > 0) { @@ -251,7 +590,7 @@ pub fn loadPortfolioData(app: *App) void { } } -pub fn sortPortfolioAllocations(app: *App) void { +pub fn sortPortfolioAllocations(state: *State, app: *App) void { if (app.portfolio.summary) |s| { const SortCtx = struct { field: PortfolioSortField, @@ -272,28 +611,31 @@ pub fn sortPortfolioAllocations(app: *App) void { }; } }; - std.mem.sort(zfin.valuation.Allocation, s.allocations, SortCtx{ .field = app.portfolio_sort_field, .dir = app.portfolio_sort_dir }, SortCtx.lessThan); + std.mem.sort(zfin.valuation.Allocation, s.allocations, SortCtx{ .field = state.sort_field, .dir = state.sort_dir }, SortCtx.lessThan); } } -pub fn rebuildPortfolioRows(app: *App) void { - app.portfolio_rows.clearRetainingCapacity(); - app.freePreparedSections(); +pub fn rebuildPortfolioRows(state: *State, app: *App) void { + state.rows.clearRetainingCapacity(); + if (state.prepared_options) |*opts| opts.deinit(); + state.prepared_options = null; + if (state.prepared_cds) |*cds| cds.deinit(); + state.prepared_cds = null; if (app.portfolio.summary) |s| { for (s.allocations, 0..) |a, i| { // Skip allocations that don't match account filter - if (!allocationMatchesFilter(app, a)) continue; + if (!allocationMatchesFilter(state, a)) continue; // Count lots for this symbol (filtered by account when filter is active) var lcount: usize = 0; - if (app.filtered_positions) |fps| { + if (state.filtered_positions) |fps| { for (fps) |pos| { if (std.mem.eql(u8, pos.symbol, a.symbol) or std.mem.eql(u8, pos.lot_symbol, a.symbol)) { lcount += pos.open_lots + pos.closed_lots; } } - } else if (app.account_filter == null) { + } else if (state.account_filter == null) { if (app.portfolio.file) |pf| { for (pf.lots) |lot| { if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) { @@ -303,7 +645,7 @@ pub fn rebuildPortfolioRows(app: *App) void { } } - app.portfolio_rows.append(app.allocator, .{ + state.rows.append(app.allocator, .{ .kind = .position, .symbol = a.symbol, .pos_idx = i, @@ -311,14 +653,14 @@ pub fn rebuildPortfolioRows(app: *App) void { }) catch continue; // Only expand if multi-lot - if (lcount > 1 and i < app.expanded.len and app.expanded[i]) { + if (lcount > 1 and i < state.expanded.len and state.expanded[i]) { if (app.portfolio.file) |pf| { // Collect matching lots, sort: open first (date desc), then closed (date desc) var matching: std.ArrayList(zfin.Lot) = .empty; defer matching.deinit(app.allocator); for (pf.lots) |lot| { if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) { - if (matchesAccountFilter(app, lot.account)) + if (matchesAccountFilter(state, lot.account)) matching.append(app.allocator, lot) catch continue; } } @@ -336,7 +678,7 @@ pub fn rebuildPortfolioRows(app: *App) void { if (!has_drip) { // No DRIP lots: show all individually for (matching.items) |lot| { - app.portfolio_rows.append(app.allocator, .{ + state.rows.append(app.allocator, .{ .kind = .lot, .symbol = lot.symbol, .pos_idx = i, @@ -347,7 +689,7 @@ pub fn rebuildPortfolioRows(app: *App) void { // Has DRIP lots: show non-DRIP individually, summarize DRIP as ST/LT for (matching.items) |lot| { if (!lot.drip) { - app.portfolio_rows.append(app.allocator, .{ + state.rows.append(app.allocator, .{ .kind = .lot, .symbol = lot.symbol, .pos_idx = i, @@ -360,7 +702,7 @@ pub fn rebuildPortfolioRows(app: *App) void { const drip = fmt.aggregateDripLots(app.today, matching.items); if (!drip.st.isEmpty()) { - app.portfolio_rows.append(app.allocator, .{ + state.rows.append(app.allocator, .{ .kind = .drip_summary, .symbol = a.symbol, .pos_idx = i, @@ -373,7 +715,7 @@ pub fn rebuildPortfolioRows(app: *App) void { }) catch {}; } if (!drip.lt.isEmpty()) { - app.portfolio_rows.append(app.allocator, .{ + state.rows.append(app.allocator, .{ .kind = .drip_summary, .symbol = a.symbol, .pos_idx = i, @@ -394,7 +736,7 @@ pub fn rebuildPortfolioRows(app: *App) void { // Add watchlist items from both the separate watchlist file and // watch lots embedded in the portfolio. Skip symbols already in allocations. // Hide watchlist entirely when account filter is active (watchlist items don't belong to accounts). - if (app.account_filter == null) { + if (state.account_filter == null) { var watch_seen = std.StringHashMap(void).init(app.allocator); defer watch_seen.deinit(); @@ -411,7 +753,7 @@ pub fn rebuildPortfolioRows(app: *App) void { if (lot.security_type == .watch) { if (watch_seen.contains(lot.priceSymbol())) continue; watch_seen.put(lot.priceSymbol(), {}) catch {}; - app.portfolio_rows.append(app.allocator, .{ + state.rows.append(app.allocator, .{ .kind = .watchlist, .symbol = lot.symbol, }) catch continue; @@ -424,7 +766,7 @@ pub fn rebuildPortfolioRows(app: *App) void { for (wl) |sym| { if (watch_seen.contains(sym)) continue; watch_seen.put(sym, {}) catch {}; - app.portfolio_rows.append(app.allocator, .{ + state.rows.append(app.allocator, .{ .kind = .watchlist, .symbol = sym, }) catch continue; @@ -434,15 +776,15 @@ pub fn rebuildPortfolioRows(app: *App) void { // Options section (sorted by expiration date, then symbol; filtered by account) if (app.portfolio.file) |pf| { - app.prepared_options = views.Options.init(app.today, app.allocator, pf.lots, app.account_filter) catch null; - if (app.prepared_options) |opts| { + state.prepared_options = views.Options.init(app.today, app.allocator, pf.lots, state.account_filter) catch null; + if (state.prepared_options) |opts| { if (opts.items.len > 0) { - app.portfolio_rows.append(app.allocator, .{ + state.rows.append(app.allocator, .{ .kind = .section_header, .symbol = "Options", }) catch {}; for (opts.items) |po| { - app.portfolio_rows.append(app.allocator, .{ + state.rows.append(app.allocator, .{ .kind = .option_row, .symbol = po.lot.symbol, .lot = po.lot, @@ -456,15 +798,15 @@ pub fn rebuildPortfolioRows(app: *App) void { } // CDs section (sorted by maturity date, earliest first; filtered by account) - app.prepared_cds = views.CDs.init(app.today, app.allocator, pf.lots, app.account_filter) catch null; - if (app.prepared_cds) |cds| { + state.prepared_cds = views.CDs.init(app.today, app.allocator, pf.lots, state.account_filter) catch null; + if (state.prepared_cds) |cds| { if (cds.items.len > 0) { - app.portfolio_rows.append(app.allocator, .{ + state.rows.append(app.allocator, .{ .kind = .section_header, .symbol = "Certificates of Deposit", }) catch {}; for (cds.items) |pc| { - app.portfolio_rows.append(app.allocator, .{ + state.rows.append(app.allocator, .{ .kind = .cd_row, .symbol = pc.lot.symbol, .lot = pc.lot, @@ -478,21 +820,21 @@ pub fn rebuildPortfolioRows(app: *App) void { // Cash section (filtered by account when filter is active) if (pf.hasType(.cash)) { // When filtered, only show cash lots matching the account - if (app.account_filter != null) { + if (state.account_filter != null) { var cash_lots: std.ArrayList(zfin.Lot) = .empty; defer cash_lots.deinit(app.allocator); for (pf.lots) |lot| { - if (lot.security_type == .cash and matchesAccountFilter(app, lot.account)) { + if (lot.security_type == .cash and matchesAccountFilter(state, lot.account)) { cash_lots.append(app.allocator, lot) catch continue; } } if (cash_lots.items.len > 0) { - app.portfolio_rows.append(app.allocator, .{ + state.rows.append(app.allocator, .{ .kind = .section_header, .symbol = "Cash", }) catch {}; for (cash_lots.items) |lot| { - app.portfolio_rows.append(app.allocator, .{ + state.rows.append(app.allocator, .{ .kind = .cash_row, .symbol = lot.account orelse "Unknown", .lot = lot, @@ -501,18 +843,18 @@ pub fn rebuildPortfolioRows(app: *App) void { } } else { // Unfiltered: show total + expandable per-account rows - app.portfolio_rows.append(app.allocator, .{ + state.rows.append(app.allocator, .{ .kind = .section_header, .symbol = "Cash", }) catch {}; - app.portfolio_rows.append(app.allocator, .{ + state.rows.append(app.allocator, .{ .kind = .cash_total, .symbol = "CASH", }) catch {}; - if (app.cash_expanded) { + if (state.cash_expanded) { for (pf.lots) |lot| { if (lot.security_type == .cash) { - app.portfolio_rows.append(app.allocator, .{ + state.rows.append(app.allocator, .{ .kind = .cash_row, .symbol = lot.account orelse "Unknown", .lot = lot, @@ -524,20 +866,20 @@ pub fn rebuildPortfolioRows(app: *App) void { } // Illiquid assets section (hidden when account filter is active) - if (app.account_filter == null) { + if (state.account_filter == null) { if (pf.hasType(.illiquid)) { - app.portfolio_rows.append(app.allocator, .{ + state.rows.append(app.allocator, .{ .kind = .section_header, .symbol = "Illiquid Assets", }) catch {}; - app.portfolio_rows.append(app.allocator, .{ + state.rows.append(app.allocator, .{ .kind = .illiquid_total, .symbol = "ILLIQUID", }) catch {}; - if (app.illiquid_expanded) { + if (state.illiquid_expanded) { for (pf.lots) |lot| { if (lot.security_type == .illiquid) { - app.portfolio_rows.append(app.allocator, .{ + state.rows.append(app.allocator, .{ .kind = .illiquid_row, .symbol = lot.symbol, .lot = lot, @@ -553,10 +895,10 @@ pub fn rebuildPortfolioRows(app: *App) void { /// Build the ordered list of distinct account names from portfolio lots. /// Order: accounts.srf file order first, then any remaining accounts alphabetically. /// Also assigns shortcut keys and loads account numbers from accounts.srf. -pub fn buildAccountList(app: *App) void { - app.account_list.clearRetainingCapacity(); - app.account_numbers.clearRetainingCapacity(); - app.account_shortcut_keys.clearRetainingCapacity(); +pub fn buildAccountList(state: *State, app: *App) void { + state.account_list.clearRetainingCapacity(); + state.account_numbers.clearRetainingCapacity(); + state.account_shortcut_keys.clearRetainingCapacity(); const pf = app.portfolio.file orelse return; @@ -582,8 +924,8 @@ pub fn buildAccountList(app: *App) void { if (app.portfolio.account_map) |am| { for (am.entries) |entry| { if (seen.contains(entry.account)) { - app.account_list.append(app.allocator, entry.account) catch continue; - app.account_numbers.append(app.allocator, entry.account_number) catch continue; + state.account_list.append(app.allocator, entry.account) catch continue; + state.account_numbers.append(app.allocator, entry.account_number) catch continue; } } } @@ -594,7 +936,7 @@ pub fn buildAccountList(app: *App) void { for (lot_accounts.items) |acct| { var found = false; - for (app.account_list.items) |existing| { + for (state.account_list.items) |existing| { if (std.mem.eql(u8, acct, existing)) { found = true; break; @@ -610,17 +952,17 @@ pub fn buildAccountList(app: *App) void { }.lessThan); for (extras.items) |acct| { - app.account_list.append(app.allocator, acct) catch continue; - app.account_numbers.append(app.allocator, null) catch continue; + state.account_list.append(app.allocator, acct) catch continue; + state.account_numbers.append(app.allocator, null) catch continue; } // Assign shortcut keys: 1-9, 0, then b-z (skipping conflict keys) - assignShortcutKeys(app); + assignShortcutKeys(state, app); // If the current filter no longer exists in the new list, clear it - if (app.account_filter) |af| { + if (state.account_filter) |af| { var found = false; - for (app.account_list.items) |acct| { + for (state.account_list.items) |acct| { if (std.mem.eql(u8, acct, af)) { found = true; break; @@ -632,41 +974,41 @@ pub fn buildAccountList(app: *App) void { const shortcut_key_order = "1234567890bcdefhimnoptuvwxyz"; -fn assignShortcutKeys(app: *App) void { - app.account_shortcut_keys.clearRetainingCapacity(); +fn assignShortcutKeys(state: *State, app: *App) void { + state.account_shortcut_keys.clearRetainingCapacity(); var key_idx: usize = 0; - for (0..app.account_list.items.len) |_| { + for (0..state.account_list.items.len) |_| { if (key_idx < shortcut_key_order.len) { - app.account_shortcut_keys.append(app.allocator, shortcut_key_order[key_idx]) catch continue; + state.account_shortcut_keys.append(app.allocator, shortcut_key_order[key_idx]) catch continue; key_idx += 1; } else { - app.account_shortcut_keys.append(app.allocator, 0) catch continue; + state.account_shortcut_keys.append(app.allocator, 0) catch continue; } } } /// Recompute filtered_positions when portfolio or account filter changes. -fn recomputeFilteredPositions(app: *App) void { - if (app.filtered_positions) |fp| app.allocator.free(fp); - app.filtered_positions = null; - const filter = app.account_filter orelse return; +fn recomputeFilteredPositions(state: *State, app: *App) void { + if (state.filtered_positions) |fp| app.allocator.free(fp); + state.filtered_positions = null; + const filter = state.account_filter orelse return; const pf = app.portfolio.file orelse return; - app.filtered_positions = pf.positionsForAccount(app.today, app.allocator, filter) catch null; + state.filtered_positions = pf.positionsForAccount(app.today, app.allocator, filter) catch null; } /// Check if a lot matches the active account filter. /// Returns true if no filter is active or the lot's account matches. -fn matchesAccountFilter(app: *const App, account: ?[]const u8) bool { - const filter = app.account_filter orelse return true; +fn matchesAccountFilter(state: *const State, account: ?[]const u8) bool { + const filter = state.account_filter orelse return true; const acct = account orelse return false; return std.mem.eql(u8, acct, filter); } /// Check if an allocation matches the active account filter. /// When filtered, checks against pre-computed filtered_positions. -fn allocationMatchesFilter(app: *const App, a: zfin.valuation.Allocation) bool { - if (app.account_filter == null) return true; - const fps = app.filtered_positions orelse return false; +fn allocationMatchesFilter(state: *const State, a: zfin.valuation.Allocation) bool { + if (state.account_filter == null) return true; + const fps = state.filtered_positions orelse return false; for (fps) |pos| { if (std.mem.eql(u8, pos.symbol, a.symbol) or std.mem.eql(u8, pos.lot_symbol, a.symbol)) return true; @@ -688,14 +1030,14 @@ const FilteredAlloc = struct { /// For filtered views, sums across all matching positions for the symbol. /// This handles rolled-up allocations where multiple positions with different /// price_ratios share the same ticker (e.g. direct SPY + institutional CIT). -fn filteredAllocValues(app: *const App, a: zfin.valuation.Allocation) FilteredAlloc { - if (app.account_filter == null) return .{ +fn filteredAllocValues(state: *const State, a: zfin.valuation.Allocation) FilteredAlloc { + if (state.account_filter == null) return .{ .shares = a.shares, .cost_basis = a.cost_basis, .market_value = a.market_value, .unrealized_gain_loss = a.unrealized_gain_loss, }; - const fps = app.filtered_positions orelse return .{ + const fps = state.filtered_positions orelse return .{ .shares = 0, .cost_basis = 0, .market_value = 0, @@ -741,14 +1083,14 @@ const FilteredTotals = struct { /// Compute total value and cost across all asset types for the active account filter. /// Returns {0, 0} if no filter is active. -fn computeFilteredTotals(app: *const App) FilteredTotals { - const af = app.account_filter orelse return .{ .value = 0, .cost = 0 }; +fn computeFilteredTotals(state: *const State, app: *const App) FilteredTotals { + const af = state.account_filter orelse return .{ .value = 0, .cost = 0 }; var value: f64 = 0; var cost: f64 = 0; if (app.portfolio.summary) |s| { for (s.allocations) |a| { - if (allocationMatchesFilter(app, a)) { - const fa = filteredAllocValues(app, a); + if (allocationMatchesFilter(state, a)) { + const fa = filteredAllocValues(state, a); value += fa.market_value; cost += fa.cost_basis; } @@ -764,7 +1106,7 @@ fn computeFilteredTotals(app: *const App) FilteredTotals { // ── Rendering ───────────────────────────────────────────────── -pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { +pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { const th = app.theme; if (app.portfolio.file == null and app.watchlist == null) { @@ -776,9 +1118,9 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); if (app.portfolio.summary) |s| { - if (app.account_filter) |af| { + if (state.account_filter) |af| { // Filtered mode: compute account-specific totals - const ft = computeFilteredTotals(app); + const ft = computeFilteredTotals(state, app); const filtered_value = ft.value; const filtered_cost = ft.cost; const filtered_gl = filtered_value - filtered_cost; @@ -864,8 +1206,8 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width // Column header (4-char prefix to match arrow(2)+star(2) in data rows) // Active sort column gets a sort indicator within the column width - const sf = app.portfolio_sort_field; - const si = app.portfolio_sort_dir.indicator(); + const sf = state.sort_field; + const si = state.sort_dir.indicator(); // Build column labels with indicator embedded in padding // Left-aligned cols: "Name▲ " Right-aligned cols: " ▼Price" var sym_hdr_buf: [16]u8 = undefined; @@ -890,19 +1232,19 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width try lines.append(arena, .{ .text = hdr, .style = th.headerStyle() }); // Track header line count for mouse click mapping (after all header lines) - app.portfolio_header_lines = lines.items.len; - app.portfolio_line_count = 0; + state.header_lines = lines.items.len; + state.line_count = 0; // Compute filtered total value for account-relative weight calculation - const filtered_total_for_weight: f64 = if (app.account_filter != null) - computeFilteredTotals(app).value + const filtered_total_for_weight: f64 = if (state.account_filter != null) + computeFilteredTotals(state, app).value else 0; // Data rows - for (app.portfolio_rows.items, 0..) |row, ri| { + for (state.rows.items, 0..) |row, ri| { const lines_before = lines.items.len; - const is_cursor = ri == app.cursor; + const is_cursor = ri == state.cursor; const is_active_sym = std.mem.eql(u8, row.symbol, app.symbol); switch (row.kind) { .position => { @@ -910,14 +1252,14 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width if (row.pos_idx < s.allocations.len) { const a = s.allocations[row.pos_idx]; // Use account-filtered values for multi-account positions - const fa = filteredAllocValues(app, a); + const fa = filteredAllocValues(state, a); const display_shares = fa.shares; const display_avg_cost = if (fa.shares > 0) fa.cost_basis / fa.shares else a.avg_cost; const display_mv = fa.market_value; const display_gl = fa.unrealized_gain_loss; const is_multi = row.lot_count > 1; - const is_expanded = is_multi and row.pos_idx < app.expanded.len and app.expanded[row.pos_idx]; + const is_expanded = is_multi and row.pos_idx < state.expanded.len and state.expanded[row.pos_idx]; const arrow: []const u8 = if (!is_multi) " " else if (is_expanded) "v " else "> "; const star: []const u8 = if (is_active_sym) "* " else " "; const pnl_pct = if (fa.cost_basis > 0) (display_gl / fa.cost_basis) * 100.0 else @as(f64, 0); @@ -944,7 +1286,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width if (app.portfolio.file) |pf| { for (pf.lots) |lot| { if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) { - if (matchesAccountFilter(app, lot.account)) { + if (matchesAccountFilter(state, lot.account)) { const ds = std.fmt.bufPrint(&pos_date_buf, "{f}", .{lot.open_date}) catch "????-??-??"; const indicator = fmt.capitalGainsIndicator(app.today, lot.open_date); date_col = std.fmt.allocPrint(arena, "{s} {s}", .{ ds, indicator }) catch ds; @@ -954,13 +1296,13 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width } } } - } else if (app.account_filter) |af| { + } else if (state.account_filter) |af| { acct_col = af; } else { acct_col = a.account; } - const display_weight = if (app.account_filter != null and filtered_total_for_weight > 0) + const display_weight = if (state.account_filter != null and filtered_total_for_weight > 0) (display_mv / filtered_total_for_weight) else a.weight; @@ -1077,7 +1419,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width .cash_total => { if (app.portfolio.file) |pf| { const total_cash = pf.totalCash(app.today); - const arrow3: []const u8 = if (app.cash_expanded) "v " else "> "; + const arrow3: []const u8 = if (state.cash_expanded) "v " else "> "; const text = try std.fmt.allocPrint(arena, " {s}Total Cash {f}", .{ arrow3, Money.from(total_cash).padRight(14), @@ -1098,7 +1440,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width .illiquid_total => { if (app.portfolio.file) |pf| { const total_illiquid = pf.totalIlliquid(app.today); - const arrow4: []const u8 = if (app.illiquid_expanded) "v " else "> "; + const arrow4: []const u8 = if (state.illiquid_expanded) "v " else "> "; const text = try std.fmt.allocPrint(arena, " {s}Total Illiquid {f}", .{ arrow4, Money.from(total_illiquid).padRight(14), @@ -1133,12 +1475,12 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width // Map all styled lines produced by this row back to the row index const lines_after = lines.items.len; for (lines_before..lines_after) |li| { - const map_idx = li - app.portfolio_header_lines; - if (map_idx < app.portfolio_line_to_row.len) { - app.portfolio_line_to_row[map_idx] = ri; + const map_idx = li - state.header_lines; + if (map_idx < state.line_to_row.len) { + state.line_to_row[map_idx] = ri; } } - app.portfolio_line_count = lines_after - app.portfolio_header_lines; + state.line_count = lines_after - state.header_lines; } // Render @@ -1179,11 +1521,11 @@ fn drawWelcomeScreen(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, wid /// Reload portfolio file from disk without re-fetching prices. /// Uses cached candle data to recompute summary. -pub fn reloadPortfolioFile(app: *App) void { +pub fn reloadPortfolioFile(state: *State, app: *App) void { // Save the account filter name before freeing the old portfolio. // account_filter is an owned copy so it survives the portfolio free, // but account_list entries borrow from the portfolio and will dangle. - app.account_list.clearRetainingCapacity(); + state.account_list.clearRetainingCapacity(); // Re-read the portfolio file if (app.portfolio.file) |*pf| pf.deinit(); @@ -1214,12 +1556,12 @@ pub fn reloadPortfolioFile(app: *App) void { // Recompute summary using cached prices (no network) app.freePortfolioSummary(); - app.expanded = @splat(false); - app.cash_expanded = false; - app.illiquid_expanded = false; - app.cursor = 0; + state.expanded = @splat(false); + state.cash_expanded = false; + state.illiquid_expanded = false; + state.cursor = 0; app.scroll_offset = 0; - app.portfolio_rows.clearRetainingCapacity(); + state.rows.clearRetainingCapacity(); const pf = app.portfolio.file orelse return; const positions = pf.positions(app.today, app.allocator) catch { @@ -1278,10 +1620,10 @@ pub fn reloadPortfolioFile(app: *App) void { pf_data.candle_map.deinit(); } - sortPortfolioAllocations(app); - buildAccountList(app); - recomputeFilteredPositions(app); - rebuildPortfolioRows(app); + sortPortfolioAllocations(state, app); + buildAccountList(state, app); + recomputeFilteredPositions(state, app); + rebuildPortfolioRows(state, app); // Invalidate analysis data -- it holds pointers into old portfolio memory if (app.states.analysis.result) |*ar| ar.deinit(app.allocator); @@ -1324,7 +1666,7 @@ pub fn reloadPortfolioFile(app: *App) void { pub const account_picker_header_lines: usize = 3; /// Draw the account picker overlay (replaces portfolio content). -pub fn drawAccountPicker(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { +pub fn drawAccountPicker(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { const th = app.theme; var lines: std.ArrayList(tui.StyledLine) = .empty; @@ -1345,7 +1687,7 @@ pub fn drawAccountPicker(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, } // Item 0 = "All accounts" (clears filter) - const total_items = app.account_list.items.len + 1; + const total_items = state.account_list.items.len + 1; for (0..total_items) |i| { const is_selected = if (is_searching) (if (search_cursor_idx) |sci| i == sci + 1 else false) @@ -1360,9 +1702,9 @@ pub fn drawAccountPicker(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, try lines.append(arena, .{ .text = text, .style = if (dimmed) th.mutedStyle() else style }); } else { const acct_idx = i - 1; - const label = app.account_list.items[acct_idx]; - const shortcut: u8 = if (acct_idx < app.account_shortcut_keys.items.len) app.account_shortcut_keys.items[acct_idx] else 0; - const acct_num: ?[]const u8 = if (acct_idx < app.account_numbers.items.len) app.account_numbers.items[acct_idx] else null; + const label = state.account_list.items[acct_idx]; + const shortcut: u8 = if (acct_idx < state.account_shortcut_keys.items.len) state.account_shortcut_keys.items[acct_idx] else 0; + const acct_num: ?[]const u8 = if (acct_idx < state.account_numbers.items.len) state.account_numbers.items[acct_idx] else null; const text = if (acct_num) |num| (if (shortcut != 0)