migrate portfolio tab to new framework
This commit is contained in:
parent
afe9eacf1d
commit
492774c04e
2 changed files with 513 additions and 343 deletions
278
src/tui.zig
278
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue