diff --git a/src/tui.zig b/src/tui.zig index 2534393..d10adc3 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -1035,11 +1035,11 @@ pub const App = struct { .earnings => { if (self.symbol.len == 0) return; if (self.earnings_disabled) return; - if (!self.earnings_loaded) self.loadEarningsData(); + if (!self.earnings_loaded) earnings_tab.loadData(self); }, .options => { if (self.symbol.len == 0) return; - if (!self.options_loaded) self.loadOptionsData(); + if (!self.options_loaded) options_tab.loadData(self); }, .analysis => { if (!self.analysis_loaded) self.loadAnalysisData(); @@ -1063,14 +1063,6 @@ pub const App = struct { perf_tab.loadData(self); } - fn loadEarningsData(self: *App) void { - earnings_tab.loadData(self); - } - - fn loadOptionsData(self: *App) void { - options_tab.loadData(self); - } - pub fn setStatus(self: *App, msg: []const u8) void { const len = @min(msg.len, self.status_msg.len); @memcpy(self.status_msg[0..len], msg[0..len]); diff --git a/src/tui/analysis_tab.zig b/src/tui/analysis_tab.zig index 04a5a6e..ee510d3 100644 --- a/src/tui/analysis_tab.zig +++ b/src/tui/analysis_tab.zig @@ -9,84 +9,84 @@ const StyledLine = tui.StyledLine; // ── Data loading ────────────────────────────────────────────── -pub fn loadData(self: *App) void { - self.analysis_loaded = true; +pub fn loadData(app: *App) void { + app.analysis_loaded = true; // Ensure portfolio is loaded first - if (!self.portfolio_loaded) self.loadPortfolioData(); - const pf = self.portfolio orelse return; - const summary = self.portfolio_summary orelse return; + if (!app.portfolio_loaded) app.loadPortfolioData(); + const pf = app.portfolio orelse return; + const summary = app.portfolio_summary orelse return; // Load classification metadata file - if (self.classification_map == null) { + if (app.classification_map == null) { // Look for metadata.srf next to the portfolio file - if (self.portfolio_path) |ppath| { + if (app.portfolio_path) |ppath| { // Derive metadata path: same directory as portfolio, named "metadata.srf" const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, '/')) |idx| idx + 1 else 0; - const meta_path = std.fmt.allocPrint(self.allocator, "{s}metadata.srf", .{ppath[0..dir_end]}) catch return; - defer self.allocator.free(meta_path); + const meta_path = std.fmt.allocPrint(app.allocator, "{s}metadata.srf", .{ppath[0..dir_end]}) catch return; + defer app.allocator.free(meta_path); - const file_data = std.fs.cwd().readFileAlloc(self.allocator, meta_path, 1024 * 1024) catch { - self.setStatus("No metadata.srf found. Run: zfin enrich > metadata.srf"); + const file_data = std.fs.cwd().readFileAlloc(app.allocator, meta_path, 1024 * 1024) catch { + app.setStatus("No metadata.srf found. Run: zfin enrich > metadata.srf"); return; }; - defer self.allocator.free(file_data); + defer app.allocator.free(file_data); - self.classification_map = zfin.classification.parseClassificationFile(self.allocator, file_data) catch { - self.setStatus("Error parsing metadata.srf"); + app.classification_map = zfin.classification.parseClassificationFile(app.allocator, file_data) catch { + app.setStatus("Error parsing metadata.srf"); return; }; } } // Load account tax type metadata file (optional) - if (self.account_map == null) { - if (self.portfolio_path) |ppath| { + if (app.account_map == null) { + if (app.portfolio_path) |ppath| { const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, '/')) |idx| idx + 1 else 0; - const acct_path = std.fmt.allocPrint(self.allocator, "{s}accounts.srf", .{ppath[0..dir_end]}) catch { - loadDataFinish(self, pf, summary); + const acct_path = std.fmt.allocPrint(app.allocator, "{s}accounts.srf", .{ppath[0..dir_end]}) catch { + loadDataFinish(app, pf, summary); return; }; - defer self.allocator.free(acct_path); + defer app.allocator.free(acct_path); - if (std.fs.cwd().readFileAlloc(self.allocator, acct_path, 1024 * 1024)) |acct_data| { - defer self.allocator.free(acct_data); - self.account_map = zfin.analysis.parseAccountsFile(self.allocator, acct_data) catch null; + if (std.fs.cwd().readFileAlloc(app.allocator, acct_path, 1024 * 1024)) |acct_data| { + defer app.allocator.free(acct_data); + app.account_map = zfin.analysis.parseAccountsFile(app.allocator, acct_data) catch null; } else |_| { // accounts.srf is optional -- analysis works without it } } } - loadDataFinish(self, pf, summary); + loadDataFinish(app, pf, summary); } -fn loadDataFinish(self: *App, pf: zfin.Portfolio, summary: zfin.valuation.PortfolioSummary) void { - const cm = self.classification_map orelse { - self.setStatus("No classification data. Run: zfin enrich > metadata.srf"); +fn loadDataFinish(app: *App, pf: zfin.Portfolio, summary: zfin.valuation.PortfolioSummary) void { + const cm = app.classification_map orelse { + app.setStatus("No classification data. Run: zfin enrich > metadata.srf"); return; }; // Free previous result - if (self.analysis_result) |*ar| ar.deinit(self.allocator); + if (app.analysis_result) |*ar| ar.deinit(app.allocator); - self.analysis_result = zfin.analysis.analyzePortfolio( - self.allocator, + app.analysis_result = zfin.analysis.analyzePortfolio( + app.allocator, summary.allocations, cm, pf, summary.total_value, - self.account_map, + app.account_map, ) catch { - self.setStatus("Error computing analysis"); + app.setStatus("Error computing analysis"); return; }; } // ── Rendering ───────────────────────────────────────────────── -pub fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { - return renderAnalysisLines(arena, self.theme, self.analysis_result); +pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine { + return renderAnalysisLines(arena, app.theme, app.analysis_result); } /// Render analysis tab content. Pure function — no App dependency. diff --git a/src/tui/earnings_tab.zig b/src/tui/earnings_tab.zig index e64307d..1011c98 100644 --- a/src/tui/earnings_tab.zig +++ b/src/tui/earnings_tab.zig @@ -9,23 +9,23 @@ const StyledLine = tui.StyledLine; // ── Data loading ────────────────────────────────────────────── -pub fn loadData(self: *App) void { - self.earnings_loaded = true; - self.freeEarnings(); +pub fn loadData(app: *App) void { + app.earnings_loaded = true; + app.freeEarnings(); - const result = self.svc.getEarnings(self.symbol) catch |err| { + const result = app.svc.getEarnings(app.symbol) catch |err| { switch (err) { - zfin.DataError.NoApiKey => self.setStatus("No API key. Set FINNHUB_API_KEY"), + zfin.DataError.NoApiKey => app.setStatus("No API key. Set FINNHUB_API_KEY"), zfin.DataError.FetchFailed => { - self.earnings_disabled = true; - self.setStatus("No earnings data (ETF/index?)"); + app.earnings_disabled = true; + app.setStatus("No earnings data (ETF/index?)"); }, - else => self.setStatus("Error loading earnings"), + else => app.setStatus("Error loading earnings"), } return; }; - self.earnings_data = result.data; - self.earnings_timestamp = result.timestamp; + app.earnings_data = result.data; + app.earnings_timestamp = result.timestamp; // Sort chronologically (oldest first) — providers may return in any order if (result.data.len > 1) { @@ -37,17 +37,17 @@ pub fn loadData(self: *App) void { } if (result.data.len == 0) { - self.earnings_disabled = true; - self.setStatus("No earnings data available (ETF/index?)"); + app.earnings_disabled = true; + app.setStatus("No earnings data available (ETF/index?)"); return; } - self.setStatus(if (result.source == .cached) "r/F5 to refresh" else "Fetched | r/F5 to refresh"); + app.setStatus(if (result.source == .cached) "r/F5 to refresh" else "Fetched | r/F5 to refresh"); } // ── Rendering ───────────────────────────────────────────────── -pub fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { - return renderEarningsLines(arena, self.theme, self.symbol, self.earnings_disabled, self.earnings_data, self.earnings_timestamp); +pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine { + return renderEarningsLines(arena, app.theme, app.symbol, app.earnings_disabled, app.earnings_data, app.earnings_timestamp); } /// Render earnings tab content. Pure function — no App dependency. diff --git a/src/tui/options_tab.zig b/src/tui/options_tab.zig index b127a62..a04f3f1 100644 --- a/src/tui/options_tab.zig +++ b/src/tui/options_tab.zig @@ -10,41 +10,41 @@ const StyledLine = tui.StyledLine; // ── Data loading ────────────────────────────────────────────── -pub fn loadData(self: *App) void { - self.options_loaded = true; - self.freeOptions(); +pub fn loadData(app: *App) void { + app.options_loaded = true; + app.freeOptions(); - const result = self.svc.getOptions(self.symbol) catch |err| { + const result = app.svc.getOptions(app.symbol) catch |err| { switch (err) { - zfin.DataError.FetchFailed => self.setStatus("CBOE fetch failed (network error)"), - else => self.setStatus("Error loading options"), + zfin.DataError.FetchFailed => app.setStatus("CBOE fetch failed (network error)"), + else => app.setStatus("Error loading options"), } return; }; - self.options_data = result.data; - self.options_timestamp = result.timestamp; - self.options_cursor = 0; - self.options_expanded = [_]bool{false} ** 64; - self.options_calls_collapsed = [_]bool{false} ** 64; - self.options_puts_collapsed = [_]bool{false} ** 64; - self.rebuildOptionsRows(); - self.setStatus(if (result.source == .cached) "Cached (1hr TTL) | r/F5 to refresh" else "Fetched | r/F5 to refresh"); + app.options_data = result.data; + app.options_timestamp = result.timestamp; + app.options_cursor = 0; + app.options_expanded = [_]bool{false} ** 64; + app.options_calls_collapsed = [_]bool{false} ** 64; + app.options_puts_collapsed = [_]bool{false} ** 64; + app.rebuildOptionsRows(); + app.setStatus(if (result.source == .cached) "Cached (1hr TTL) | r/F5 to refresh" else "Fetched | r/F5 to refresh"); } // ── Rendering ───────────────────────────────────────────────── -pub fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { - const th = self.theme; +pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine { + const th = app.theme; var lines: std.ArrayList(StyledLine) = .empty; try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); - if (self.symbol.len == 0) { + if (app.symbol.len == 0) { try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() }); return lines.toOwnedSlice(arena); } - const chains = self.options_data orelse { + const chains = app.options_data orelse { try lines.append(arena, .{ .text = " Loading options data...", .style = th.mutedStyle() }); return lines.toOwnedSlice(arena); }; @@ -55,32 +55,32 @@ pub fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLin } var opt_ago_buf: [16]u8 = undefined; - const opt_ago = fmt.fmtTimeAgo(&opt_ago_buf, self.options_timestamp); + const opt_ago = fmt.fmtTimeAgo(&opt_ago_buf, app.options_timestamp); if (opt_ago.len > 0) { - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Options: {s} (data {s}, 15 min delay)", .{ self.symbol, opt_ago }), .style = th.headerStyle() }); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Options: {s} (data {s}, 15 min delay)", .{ app.symbol, opt_ago }), .style = th.headerStyle() }); } else { - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Options: {s}", .{self.symbol}), .style = th.headerStyle() }); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Options: {s}", .{app.symbol}), .style = th.headerStyle() }); } if (chains[0].underlying_price) |price| { var price_buf: [24]u8 = undefined; - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Underlying: {s} {d} expiration(s) +/- {d} strikes NTM (Ctrl+1-9 to change)", .{ fmt.fmtMoneyAbs(&price_buf, price), chains.len, self.options_near_the_money }), .style = th.contentStyle() }); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Underlying: {s} {d} expiration(s) +/- {d} strikes NTM (Ctrl+1-9 to change)", .{ fmt.fmtMoneyAbs(&price_buf, price), chains.len, app.options_near_the_money }), .style = th.contentStyle() }); } try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); // Track header line count for mouse click mapping (after all non-data lines) - self.options_header_lines = lines.items.len; + app.options_header_lines = lines.items.len; // Flat list of options rows with inline expand/collapse - for (self.options_rows.items, 0..) |row, ri| { - const is_cursor = ri == self.options_cursor; + for (app.options_rows.items, 0..) |row, ri| { + const is_cursor = ri == app.options_cursor; switch (row.kind) { .expiration => { if (row.exp_idx < chains.len) { const chain = chains[row.exp_idx]; var db: [10]u8 = undefined; - const is_expanded = row.exp_idx < self.options_expanded.len and self.options_expanded[row.exp_idx]; + const is_expanded = row.exp_idx < app.options_expanded.len and app.options_expanded[row.exp_idx]; const is_monthly = fmt.isMonthlyExpiration(chain.expiration); const arrow: []const u8 = if (is_expanded) "v " else "> "; const text = try std.fmt.allocPrint(arena, " {s}{s} ({d} calls, {d} puts)", .{ @@ -94,7 +94,7 @@ pub fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLin } }, .calls_header => { - const calls_collapsed = row.exp_idx < self.options_calls_collapsed.len and self.options_calls_collapsed[row.exp_idx]; + const calls_collapsed = row.exp_idx < app.options_calls_collapsed.len and app.options_calls_collapsed[row.exp_idx]; const arrow: []const u8 = if (calls_collapsed) " > " else " v "; const style = if (is_cursor) th.selectStyle() else th.headerStyle(); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "{s}{s:>10} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8} {s:>8} Calls", .{ @@ -102,7 +102,7 @@ pub fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLin }), .style = style }); }, .puts_header => { - const puts_collapsed = row.exp_idx < self.options_puts_collapsed.len and self.options_puts_collapsed[row.exp_idx]; + const puts_collapsed = row.exp_idx < app.options_puts_collapsed.len and app.options_puts_collapsed[row.exp_idx]; const arrow: []const u8 = if (puts_collapsed) " > " else " v "; try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); const style = if (is_cursor) th.selectStyle() else th.headerStyle(); diff --git a/src/tui/perf_tab.zig b/src/tui/perf_tab.zig index 25d2292..a82f7fb 100644 --- a/src/tui/perf_tab.zig +++ b/src/tui/perf_tab.zig @@ -10,116 +10,116 @@ const StyledLine = tui.StyledLine; // ── Data loading ────────────────────────────────────────────── -pub fn loadData(self: *App) void { - self.perf_loaded = true; - self.freeCandles(); - self.freeDividends(); - self.trailing_price = null; - self.trailing_total = null; - self.trailing_me_price = null; - self.trailing_me_total = null; - self.candle_count = 0; - self.candle_first_date = null; - self.candle_last_date = null; +pub fn loadData(app: *App) void { + app.perf_loaded = true; + app.freeCandles(); + app.freeDividends(); + app.trailing_price = null; + app.trailing_total = null; + app.trailing_me_price = null; + app.trailing_me_total = null; + app.candle_count = 0; + app.candle_first_date = null; + app.candle_last_date = null; - const candle_result = self.svc.getCandles(self.symbol) catch |err| { + const candle_result = app.svc.getCandles(app.symbol) catch |err| { switch (err) { - zfin.DataError.NoApiKey => self.setStatus("No API key. Set TWELVEDATA_API_KEY"), - zfin.DataError.FetchFailed => self.setStatus("Fetch failed (network error or rate limit)"), - else => self.setStatus("Error loading data"), + zfin.DataError.NoApiKey => app.setStatus("No API key. Set TWELVEDATA_API_KEY"), + zfin.DataError.FetchFailed => app.setStatus("Fetch failed (network error or rate limit)"), + else => app.setStatus("Error loading data"), } return; }; - self.candles = candle_result.data; - self.candle_timestamp = candle_result.timestamp; + app.candles = candle_result.data; + app.candle_timestamp = candle_result.timestamp; - const c = self.candles.?; + const c = app.candles.?; if (c.len == 0) { - self.setStatus("No data available for symbol"); + app.setStatus("No data available for symbol"); return; } - self.candle_count = c.len; - self.candle_first_date = c[0].date; - self.candle_last_date = c[c.len - 1].date; + app.candle_count = c.len; + app.candle_first_date = c[0].date; + app.candle_last_date = c[c.len - 1].date; const today = fmt.todayDate(); - self.trailing_price = zfin.performance.trailingReturns(c); - self.trailing_me_price = zfin.performance.trailingReturnsMonthEnd(c, today); + app.trailing_price = zfin.performance.trailingReturns(c); + app.trailing_me_price = zfin.performance.trailingReturnsMonthEnd(c, today); - if (self.svc.getDividends(self.symbol)) |div_result| { - self.dividends = div_result.data; - self.trailing_total = zfin.performance.trailingReturnsWithDividends(c, div_result.data); - self.trailing_me_total = zfin.performance.trailingReturnsMonthEndWithDividends(c, div_result.data, today); + if (app.svc.getDividends(app.symbol)) |div_result| { + app.dividends = div_result.data; + app.trailing_total = zfin.performance.trailingReturnsWithDividends(c, div_result.data); + app.trailing_me_total = zfin.performance.trailingReturnsMonthEndWithDividends(c, div_result.data, today); } else |_| {} - self.risk_metrics = zfin.risk.trailingRisk(c); + app.risk_metrics = zfin.risk.trailingRisk(c); // Try to load ETF profile (non-fatal, won't show for non-ETFs) - if (!self.etf_loaded) { - self.etf_loaded = true; - if (self.svc.getEtfProfile(self.symbol)) |etf_result| { + if (!app.etf_loaded) { + app.etf_loaded = true; + if (app.svc.getEtfProfile(app.symbol)) |etf_result| { if (etf_result.data.isEtf()) { - self.etf_profile = etf_result.data; + app.etf_profile = etf_result.data; } } else |_| {} } - self.setStatus(if (candle_result.source == .cached) "r/F5 to refresh" else "Fetched | r/F5 to refresh"); + app.setStatus(if (candle_result.source == .cached) "r/F5 to refresh" else "Fetched | r/F5 to refresh"); } // ── Rendering ───────────────────────────────────────────────── -pub fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { - const th = self.theme; +pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine { + const th = app.theme; var lines: std.ArrayList(StyledLine) = .empty; try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); - if (self.symbol.len == 0) { + if (app.symbol.len == 0) { try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() }); return lines.toOwnedSlice(arena); } - if (self.candle_last_date) |d| { + if (app.candle_last_date) |d| { var pdate_buf: [10]u8 = undefined; - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Trailing Returns: {s} (as of close on {s})", .{ self.symbol, d.format(&pdate_buf) }), .style = th.headerStyle() }); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Trailing Returns: {s} (as of close on {s})", .{ app.symbol, d.format(&pdate_buf) }), .style = th.headerStyle() }); } else { - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Trailing Returns: {s}", .{self.symbol}), .style = th.headerStyle() }); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Trailing Returns: {s}", .{app.symbol}), .style = th.headerStyle() }); } try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); - if (self.trailing_price == null) { - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " No data. Run: zfin perf {s}", .{self.symbol}), .style = th.mutedStyle() }); + if (app.trailing_price == null) { + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " No data. Run: zfin perf {s}", .{app.symbol}), .style = th.mutedStyle() }); return lines.toOwnedSlice(arena); } - if (self.candle_count > 0) { - if (self.candle_first_date) |first| { - if (self.candle_last_date) |last| { + if (app.candle_count > 0) { + if (app.candle_first_date) |first| { + if (app.candle_last_date) |last| { var fb: [10]u8 = undefined; var lb: [10]u8 = undefined; try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Data: {d} points ({s} to {s})", .{ - self.candle_count, first.format(&fb), last.format(&lb), + app.candle_count, first.format(&fb), last.format(&lb), }), .style = th.mutedStyle() }); } } } - if (self.candles) |cc| { + if (app.candles) |cc| { if (cc.len > 0) { var close_buf: [24]u8 = undefined; try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Latest close: {s}", .{fmt.fmtMoneyAbs(&close_buf, cc[cc.len - 1].close)}), .style = th.contentStyle() }); } } - const has_total = self.trailing_total != null; + const has_total = app.trailing_total != null; - if (self.candle_last_date) |last| { + if (app.candle_last_date) |last| { var db: [10]u8 = undefined; try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " As-of {s}:", .{last.format(&db)}), .style = th.headerStyle() }); } - try appendStyledReturnsTable(arena, &lines, self.trailing_price.?, if (has_total) self.trailing_total else null, th); + try appendStyledReturnsTable(arena, &lines, app.trailing_price.?, if (has_total) app.trailing_total else null, th); { const today = fmt.todayDate(); @@ -128,8 +128,8 @@ pub fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLin try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Month-end ({s}):", .{month_end.format(&db)}), .style = th.headerStyle() }); } - if (self.trailing_me_price) |me_price| { - try appendStyledReturnsTable(arena, &lines, me_price, if (has_total) self.trailing_me_total else null, th); + if (app.trailing_me_price) |me_price| { + try appendStyledReturnsTable(arena, &lines, me_price, if (has_total) app.trailing_me_total else null, th); } if (!has_total) { @@ -137,7 +137,7 @@ pub fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLin try lines.append(arena, .{ .text = " (Set POLYGON_API_KEY for total returns with dividends)", .style = th.dimStyle() }); } - if (self.risk_metrics) |tr| { + if (app.risk_metrics) |tr| { try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = " Risk Metrics (monthly returns):", .style = th.headerStyle() }); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14} {s:>14} {s:>14}", .{ "", "Volatility", "Sharpe", "Max DD" }), .style = th.mutedStyle() }); diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index 92797cd..c4cb0ca 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -44,27 +44,27 @@ const gl_col_start: usize = col_end_market_value; /// 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(self: *App) void { - self.portfolio_loaded = true; - self.freePortfolioSummary(); +pub fn loadPortfolioData(app: *App) void { + app.portfolio_loaded = true; + app.freePortfolioSummary(); - const pf = self.portfolio orelse return; + const pf = app.portfolio orelse return; - const positions = pf.positions(self.allocator) catch { - self.setStatus("Error computing positions"); + const positions = pf.positions(app.allocator) catch { + app.setStatus("Error computing positions"); return; }; - defer self.allocator.free(positions); + defer app.allocator.free(positions); - var prices = std.StringHashMap(f64).init(self.allocator); + var prices = std.StringHashMap(f64).init(app.allocator); defer prices.deinit(); // Only fetch prices for stock/ETF symbols (skip options, CDs, cash) - const syms = pf.stockSymbols(self.allocator) catch { - self.setStatus("Error getting symbols"); + const syms = pf.stockSymbols(app.allocator) catch { + app.setStatus("Error getting symbols"); return; }; - defer self.allocator.free(syms); + defer app.allocator.free(syms); var latest_date: ?zfin.Date = null; var fail_count: usize = 0; @@ -72,7 +72,7 @@ pub fn loadPortfolioData(self: *App) void { var stale_count: usize = 0; var failed_syms: [8][]const u8 = undefined; - if (self.prefetched_prices) |*pp| { + if (app.prefetched_prices) |*pp| { // Use pre-fetched prices from before TUI started (first load only) // Move stock prices into the working map for (syms) |sym| { @@ -82,10 +82,10 @@ pub fn loadPortfolioData(self: *App) void { } // Extract watchlist prices - if (self.watchlist_prices) |*wp| wp.clearRetainingCapacity() else { - self.watchlist_prices = std.StringHashMap(f64).init(self.allocator); + if (app.watchlist_prices) |*wp| wp.clearRetainingCapacity() else { + app.watchlist_prices = std.StringHashMap(f64).init(app.allocator); } - var wp = &(self.watchlist_prices.?); + var wp = &(app.watchlist_prices.?); var pp_iter = pp.iterator(); while (pp_iter.next()) |entry| { if (!prices.contains(entry.key_ptr.*)) { @@ -94,17 +94,17 @@ pub fn loadPortfolioData(self: *App) void { } pp.deinit(); - self.prefetched_prices = null; + app.prefetched_prices = null; } else { // Live fetch (refresh path) — fetch watchlist first, then stock prices - if (self.watchlist_prices) |*wp| wp.clearRetainingCapacity() else { - self.watchlist_prices = std.StringHashMap(f64).init(self.allocator); + if (app.watchlist_prices) |*wp| wp.clearRetainingCapacity() else { + app.watchlist_prices = std.StringHashMap(f64).init(app.allocator); } - var wp = &(self.watchlist_prices.?); - if (self.watchlist) |wl| { + var wp = &(app.watchlist_prices.?); + if (app.watchlist) |wl| { for (wl) |sym| { - const result = self.svc.getCandles(sym) catch continue; - defer self.allocator.free(result.data); + const result = app.svc.getCandles(sym) catch continue; + defer app.allocator.free(result.data); if (result.data.len > 0) { wp.put(sym, result.data[result.data.len - 1].close) catch {}; } @@ -113,8 +113,8 @@ pub fn loadPortfolioData(self: *App) void { for (pf.lots) |lot| { if (lot.security_type == .watch) { const sym = lot.priceSymbol(); - const result = self.svc.getCandles(sym) catch continue; - defer self.allocator.free(result.data); + const result = app.svc.getCandles(sym) catch continue; + defer app.allocator.free(result.data); if (result.data.len > 0) { wp.put(sym, result.data[result.data.len - 1].close) catch {}; } @@ -152,46 +152,46 @@ pub fn loadPortfolioData(self: *App) void { }; } }; - var tui_progress = TuiProgress{ .app = self, .failed = &failed_syms }; - const load_result = self.svc.loadPrices(syms, &prices, false, tui_progress.callback()); + var tui_progress = TuiProgress{ .app = app, .failed = &failed_syms }; + const load_result = app.svc.loadPrices(syms, &prices, false, tui_progress.callback()); latest_date = load_result.latest_date; fail_count = load_result.fail_count; fetch_count = load_result.fetched_count; stale_count = load_result.stale_count; } - self.candle_last_date = latest_date; + app.candle_last_date = latest_date; // Build portfolio summary, candle map, and historical snapshots - var pf_data = cli.buildPortfolioData(self.allocator, pf, positions, syms, &prices, self.svc) catch |err| switch (err) { + var pf_data = cli.buildPortfolioData(app.allocator, pf, positions, syms, &prices, app.svc) catch |err| switch (err) { error.NoAllocations => { - self.setStatus("No cached prices. Run: zfin perf first"); + app.setStatus("No cached prices. Run: zfin perf first"); return; }, error.SummaryFailed => { - self.setStatus("Error computing portfolio summary"); + app.setStatus("Error computing portfolio summary"); return; }, else => { - self.setStatus("Error building portfolio data"); + app.setStatus("Error building portfolio data"); return; }, }; // Transfer ownership: summary stored on App, candle_map freed after snapshots extracted - self.portfolio_summary = pf_data.summary; - self.historical_snapshots = pf_data.snapshots; + app.portfolio_summary = pf_data.summary; + app.historical_snapshots = pf_data.snapshots; { // Free candle_map values and map (snapshots are value types, already copied) var it = pf_data.candle_map.valueIterator(); - while (it.next()) |v| self.allocator.free(v.*); + while (it.next()) |v| app.allocator.free(v.*); pf_data.candle_map.deinit(); } - sortPortfolioAllocations(self); - rebuildPortfolioRows(self); + sortPortfolioAllocations(app); + rebuildPortfolioRows(app); const summary = pf_data.summary; - if (self.symbol.len == 0 and summary.allocations.len > 0) { - self.setActiveSymbol(summary.allocations[0].symbol); + if (app.symbol.len == 0 and summary.allocations.len > 0) { + app.setActiveSymbol(summary.allocations[0].symbol); } // Show warning if any securities failed to load @@ -217,31 +217,31 @@ pub fn loadPortfolioData(self: *App) void { } if (stale_count > 0) { const warn_msg = std.fmt.bufPrint(&warn_buf, "Failed to refresh: {s} (using stale cache)", .{sym_buf[0..sym_len]}) catch "Warning: some securities failed"; - self.setStatus(warn_msg); + app.setStatus(warn_msg); } else { const warn_msg = std.fmt.bufPrint(&warn_buf, "Failed to load: {s}", .{sym_buf[0..sym_len]}) catch "Warning: some securities failed"; - self.setStatus(warn_msg); + app.setStatus(warn_msg); } } else { if (stale_count > 0 and stale_count == fail_count) { const warn_msg = std.fmt.bufPrint(&warn_buf, "{d} symbols failed to refresh (using stale cache) | r/F5 to retry", .{fail_count}) catch "Warning: some securities used stale cache"; - self.setStatus(warn_msg); + app.setStatus(warn_msg); } else { const warn_msg = std.fmt.bufPrint(&warn_buf, "Warning: {d} securities failed to load prices", .{fail_count}) catch "Warning: some securities failed"; - self.setStatus(warn_msg); + app.setStatus(warn_msg); } } } else if (fetch_count > 0) { var info_buf: [128]u8 = undefined; const info_msg = std.fmt.bufPrint(&info_buf, "Loaded {d} symbols ({d} fetched) | r/F5 to refresh", .{ syms.len, fetch_count }) catch "Loaded | r/F5 to refresh"; - self.setStatus(info_msg); + app.setStatus(info_msg); } else { - self.setStatus("j/k navigate | Enter expand | s select symbol | / search | ? help"); + app.setStatus("j/k navigate | Enter expand | s select symbol | / search | ? help"); } } -pub fn sortPortfolioAllocations(self: *App) void { - if (self.portfolio_summary) |s| { +pub fn sortPortfolioAllocations(app: *App) void { + if (app.portfolio_summary) |s| { const SortCtx = struct { field: PortfolioSortField, dir: tui.SortDirection, @@ -261,24 +261,24 @@ pub fn sortPortfolioAllocations(self: *App) void { }; } }; - std.mem.sort(zfin.valuation.Allocation, s.allocations, SortCtx{ .field = self.portfolio_sort_field, .dir = self.portfolio_sort_dir }, SortCtx.lessThan); + std.mem.sort(zfin.valuation.Allocation, s.allocations, SortCtx{ .field = app.portfolio_sort_field, .dir = app.portfolio_sort_dir }, SortCtx.lessThan); } } -pub fn rebuildPortfolioRows(self: *App) void { - self.portfolio_rows.clearRetainingCapacity(); +pub fn rebuildPortfolioRows(app: *App) void { + app.portfolio_rows.clearRetainingCapacity(); - if (self.portfolio_summary) |s| { + if (app.portfolio_summary) |s| { for (s.allocations, 0..) |a, i| { // Count lots for this symbol var lcount: usize = 0; - if (self.portfolio) |pf| { + if (app.portfolio) |pf| { for (pf.lots) |lot| { if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) lcount += 1; } } - self.portfolio_rows.append(self.allocator, .{ + app.portfolio_rows.append(app.allocator, .{ .kind = .position, .symbol = a.symbol, .pos_idx = i, @@ -286,14 +286,14 @@ pub fn rebuildPortfolioRows(self: *App) void { }) catch continue; // Only expand if multi-lot - if (lcount > 1 and i < self.expanded.len and self.expanded[i]) { - if (self.portfolio) |pf| { + if (lcount > 1 and i < app.expanded.len and app.expanded[i]) { + if (app.portfolio) |pf| { // Collect matching lots, sort: open first (date desc), then closed (date desc) var matching: std.ArrayList(zfin.Lot) = .empty; - defer matching.deinit(self.allocator); + defer matching.deinit(app.allocator); for (pf.lots) |lot| { if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) { - matching.append(self.allocator, lot) catch continue; + matching.append(app.allocator, lot) catch continue; } } std.mem.sort(zfin.Lot, matching.items, {}, fmt.lotSortFn); @@ -310,7 +310,7 @@ pub fn rebuildPortfolioRows(self: *App) void { if (!has_drip) { // No DRIP lots: show all individually for (matching.items) |lot| { - self.portfolio_rows.append(self.allocator, .{ + app.portfolio_rows.append(app.allocator, .{ .kind = .lot, .symbol = lot.symbol, .pos_idx = i, @@ -321,7 +321,7 @@ pub fn rebuildPortfolioRows(self: *App) void { // Has DRIP lots: show non-DRIP individually, summarize DRIP as ST/LT for (matching.items) |lot| { if (!lot.drip) { - self.portfolio_rows.append(self.allocator, .{ + app.portfolio_rows.append(app.allocator, .{ .kind = .lot, .symbol = lot.symbol, .pos_idx = i, @@ -334,7 +334,7 @@ pub fn rebuildPortfolioRows(self: *App) void { const drip = fmt.aggregateDripLots(matching.items); if (!drip.st.isEmpty()) { - self.portfolio_rows.append(self.allocator, .{ + app.portfolio_rows.append(app.allocator, .{ .kind = .drip_summary, .symbol = a.symbol, .pos_idx = i, @@ -347,7 +347,7 @@ pub fn rebuildPortfolioRows(self: *App) void { }) catch {}; } if (!drip.lt.isEmpty()) { - self.portfolio_rows.append(self.allocator, .{ + app.portfolio_rows.append(app.allocator, .{ .kind = .drip_summary, .symbol = a.symbol, .pos_idx = i, @@ -367,23 +367,23 @@ pub fn rebuildPortfolioRows(self: *App) void { // Add watchlist items from both the separate watchlist file and // watch lots embedded in the portfolio. Skip symbols already in allocations. - var watch_seen = std.StringHashMap(void).init(self.allocator); + var watch_seen = std.StringHashMap(void).init(app.allocator); defer watch_seen.deinit(); // Mark all portfolio position symbols as seen - if (self.portfolio_summary) |s| { + if (app.portfolio_summary) |s| { for (s.allocations) |a| { watch_seen.put(a.symbol, {}) catch {}; } } // Watch lots from portfolio file - if (self.portfolio) |pf| { + if (app.portfolio) |pf| { for (pf.lots) |lot| { if (lot.security_type == .watch) { if (watch_seen.contains(lot.priceSymbol())) continue; watch_seen.put(lot.priceSymbol(), {}) catch {}; - self.portfolio_rows.append(self.allocator, .{ + app.portfolio_rows.append(app.allocator, .{ .kind = .watchlist, .symbol = lot.symbol, }) catch continue; @@ -392,11 +392,11 @@ pub fn rebuildPortfolioRows(self: *App) void { } // Separate watchlist file (backward compat) - if (self.watchlist) |wl| { + if (app.watchlist) |wl| { for (wl) |sym| { if (watch_seen.contains(sym)) continue; watch_seen.put(sym, {}) catch {}; - self.portfolio_rows.append(self.allocator, .{ + app.portfolio_rows.append(app.allocator, .{ .kind = .watchlist, .symbol = sym, }) catch continue; @@ -404,15 +404,15 @@ pub fn rebuildPortfolioRows(self: *App) void { } // Options section - if (self.portfolio) |pf| { + if (app.portfolio) |pf| { if (pf.hasType(.option)) { - self.portfolio_rows.append(self.allocator, .{ + app.portfolio_rows.append(app.allocator, .{ .kind = .section_header, .symbol = "Options", }) catch {}; for (pf.lots) |lot| { if (lot.security_type == .option) { - self.portfolio_rows.append(self.allocator, .{ + app.portfolio_rows.append(app.allocator, .{ .kind = .option_row, .symbol = lot.symbol, .lot = lot, @@ -423,20 +423,20 @@ pub fn rebuildPortfolioRows(self: *App) void { // CDs section (sorted by maturity date, earliest first) if (pf.hasType(.cd)) { - self.portfolio_rows.append(self.allocator, .{ + app.portfolio_rows.append(app.allocator, .{ .kind = .section_header, .symbol = "Certificates of Deposit", }) catch {}; var cd_lots: std.ArrayList(zfin.Lot) = .empty; - defer cd_lots.deinit(self.allocator); + defer cd_lots.deinit(app.allocator); for (pf.lots) |lot| { if (lot.security_type == .cd) { - cd_lots.append(self.allocator, lot) catch continue; + cd_lots.append(app.allocator, lot) catch continue; } } std.mem.sort(zfin.Lot, cd_lots.items, {}, fmt.lotMaturitySortFn); for (cd_lots.items) |lot| { - self.portfolio_rows.append(self.allocator, .{ + app.portfolio_rows.append(app.allocator, .{ .kind = .cd_row, .symbol = lot.symbol, .lot = lot, @@ -446,20 +446,20 @@ pub fn rebuildPortfolioRows(self: *App) void { // Cash section (single total row, expandable to show per-account) if (pf.hasType(.cash)) { - self.portfolio_rows.append(self.allocator, .{ + app.portfolio_rows.append(app.allocator, .{ .kind = .section_header, .symbol = "Cash", }) catch {}; // Total cash row - self.portfolio_rows.append(self.allocator, .{ + app.portfolio_rows.append(app.allocator, .{ .kind = .cash_total, .symbol = "CASH", }) catch {}; // Per-account cash rows (expanded when cash_total is toggled) - if (self.cash_expanded) { + if (app.cash_expanded) { for (pf.lots) |lot| { if (lot.security_type == .cash) { - self.portfolio_rows.append(self.allocator, .{ + app.portfolio_rows.append(app.allocator, .{ .kind = .cash_row, .symbol = lot.account orelse "Unknown", .lot = lot, @@ -471,20 +471,20 @@ pub fn rebuildPortfolioRows(self: *App) void { // Illiquid assets section (similar to cash: total row, expandable) if (pf.hasType(.illiquid)) { - self.portfolio_rows.append(self.allocator, .{ + app.portfolio_rows.append(app.allocator, .{ .kind = .section_header, .symbol = "Illiquid Assets", }) catch {}; // Total illiquid row - self.portfolio_rows.append(self.allocator, .{ + app.portfolio_rows.append(app.allocator, .{ .kind = .illiquid_total, .symbol = "ILLIQUID", }) catch {}; // Per-asset rows (expanded when illiquid_total is toggled) - if (self.illiquid_expanded) { + if (app.illiquid_expanded) { for (pf.lots) |lot| { if (lot.security_type == .illiquid) { - self.portfolio_rows.append(self.allocator, .{ + app.portfolio_rows.append(app.allocator, .{ .kind = .illiquid_row, .symbol = lot.symbol, .lot = lot, @@ -498,18 +498,18 @@ pub fn rebuildPortfolioRows(self: *App) void { // ── Rendering ───────────────────────────────────────────────── -pub fn drawContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { - const th = self.theme; +pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { + const th = app.theme; - if (self.portfolio == null and self.watchlist == null) { - try drawWelcomeScreen(self, arena, buf, width, height); + if (app.portfolio == null and app.watchlist == null) { + try drawWelcomeScreen(app, arena, buf, width, height); return; } var lines: std.ArrayList(StyledLine) = .empty; try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); - if (self.portfolio_summary) |s| { + if (app.portfolio_summary) |s| { var val_buf: [24]u8 = undefined; var cost_buf: [24]u8 = undefined; var gl_buf: [24]u8 = undefined; @@ -524,14 +524,14 @@ pub fn drawContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, widt try lines.append(arena, .{ .text = summary_text, .style = summary_style }); // "as of" date indicator - if (self.candle_last_date) |d| { + if (app.candle_last_date) |d| { var asof_buf: [10]u8 = undefined; const asof_text = try std.fmt.allocPrint(arena, " (as of close on {s})", .{d.format(&asof_buf)}); try lines.append(arena, .{ .text = asof_text, .style = th.mutedStyle() }); } // Net Worth line (only if portfolio has illiquid assets) - if (self.portfolio) |pf| { + if (app.portfolio) |pf| { if (pf.hasType(.illiquid)) { const illiquid_total = pf.totalIlliquid(); const net_worth = s.total_value + illiquid_total; @@ -547,7 +547,7 @@ pub fn drawContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, widt } // Historical portfolio value snapshots - if (self.historical_snapshots) |snapshots| { + if (app.historical_snapshots) |snapshots| { try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); // Build a single-line summary: " Historical: 1M: +3.2% 3M: +8.1% 1Y: +22.4% 3Y: +45.1% 5Y: -- 10Y: --" var hist_parts: [6][]const u8 = undefined; @@ -562,7 +562,7 @@ pub fn drawContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, widt }); try lines.append(arena, .{ .text = hist_text, .style = th.mutedStyle() }); } - } else if (self.portfolio != null) { + } else if (app.portfolio != null) { try lines.append(arena, .{ .text = " No cached prices. Run 'zfin perf ' for each holding.", .style = th.mutedStyle() }); } else { try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); @@ -573,8 +573,8 @@ pub fn drawContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, widt // 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 = self.portfolio_sort_field; - const si = self.portfolio_sort_dir.indicator(); + const sf = app.portfolio_sort_field; + const si = app.portfolio_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; @@ -599,21 +599,21 @@ pub fn drawContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, widt try lines.append(arena, .{ .text = hdr, .style = th.headerStyle() }); // Track header line count for mouse click mapping (after all header lines) - self.portfolio_header_lines = lines.items.len; - self.portfolio_line_count = 0; + app.portfolio_header_lines = lines.items.len; + app.portfolio_line_count = 0; // Data rows - for (self.portfolio_rows.items, 0..) |row, ri| { + for (app.portfolio_rows.items, 0..) |row, ri| { const lines_before = lines.items.len; - const is_cursor = ri == self.cursor; - const is_active_sym = std.mem.eql(u8, row.symbol, self.symbol); + const is_cursor = ri == app.cursor; + const is_active_sym = std.mem.eql(u8, row.symbol, app.symbol); switch (row.kind) { .position => { - if (self.portfolio_summary) |s| { + if (app.portfolio_summary) |s| { if (row.pos_idx < s.allocations.len) { const a = s.allocations[row.pos_idx]; const is_multi = row.lot_count > 1; - const is_expanded = is_multi and row.pos_idx < self.expanded.len and self.expanded[row.pos_idx]; + const is_expanded = is_multi and row.pos_idx < app.expanded.len and app.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 (a.cost_basis > 0) (a.unrealized_gain_loss / a.cost_basis) * 100.0 else @as(f64, 0); @@ -637,7 +637,7 @@ pub fn drawContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, widt var date_col: []const u8 = ""; var acct_col: []const u8 = ""; if (!is_multi) { - if (self.portfolio) |pf| { + if (app.portfolio) |pf| { for (pf.lots) |lot| { if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) { const ds = lot.open_date.format(&pos_date_buf); @@ -650,7 +650,7 @@ pub fn drawContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, widt } } else { // Multi-lot: show account if all lots share the same one - if (self.portfolio) |pf| { + if (app.portfolio) |pf| { var common_acct: ?[]const u8 = null; var mixed = false; for (pf.lots) |lot| { @@ -702,7 +702,7 @@ pub fn drawContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, widt var lot_gl_str: []const u8 = ""; var lot_mv_str: []const u8 = ""; var lot_positive = true; - if (self.portfolio_summary) |s| { + if (app.portfolio_summary) |s| { if (row.pos_idx < s.allocations.len) { const price = s.allocations[row.pos_idx].current_price; const use_price = lot.close_price orelse price; @@ -740,7 +740,7 @@ pub fn drawContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, widt }, .watchlist => { var price_str3: [16]u8 = undefined; - const ps: []const u8 = if (self.watchlist_prices) |wp| + const ps: []const u8 = if (app.watchlist_prices) |wp| (if (wp.get(row.symbol)) |p| fmt.fmtMoneyAbs(&price_str3, p) else "--") else "--"; @@ -818,10 +818,10 @@ pub fn drawContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, widt } }, .cash_total => { - if (self.portfolio) |pf| { + if (app.portfolio) |pf| { const total_cash = pf.totalCash(); var cash_buf: [24]u8 = undefined; - const arrow3: []const u8 = if (self.cash_expanded) "v " else "> "; + const arrow3: []const u8 = if (app.cash_expanded) "v " else "> "; const text = try std.fmt.allocPrint(arena, " {s}Total Cash {s:>14}", .{ arrow3, fmt.fmtMoneyAbs(&cash_buf, total_cash), @@ -840,10 +840,10 @@ pub fn drawContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, widt } }, .illiquid_total => { - if (self.portfolio) |pf| { + if (app.portfolio) |pf| { const total_illiquid = pf.totalIlliquid(); var illiquid_buf: [24]u8 = undefined; - const arrow4: []const u8 = if (self.illiquid_expanded) "v " else "> "; + const arrow4: []const u8 = if (app.illiquid_expanded) "v " else "> "; const text = try std.fmt.allocPrint(arena, " {s}Total Illiquid {s:>14}", .{ arrow4, fmt.fmtMoneyAbs(&illiquid_buf, total_illiquid), @@ -878,21 +878,21 @@ pub fn drawContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, widt // 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 - self.portfolio_header_lines; - if (map_idx < self.portfolio_line_to_row.len) { - self.portfolio_line_to_row[map_idx] = ri; + 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; } } - self.portfolio_line_count = lines_after - self.portfolio_header_lines; + app.portfolio_line_count = lines_after - app.portfolio_header_lines; } // Render - const start = @min(self.scroll_offset, if (lines.items.len > 0) lines.items.len - 1 else 0); - try self.drawStyledContent(arena, buf, width, height, lines.items[start..]); + const start = @min(app.scroll_offset, if (lines.items.len > 0) lines.items.len - 1 else 0); + try app.drawStyledContent(arena, buf, width, height, lines.items[start..]); } -fn drawWelcomeScreen(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { - const th = self.theme; +fn drawWelcomeScreen(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { + const th = app.theme; const welcome_lines = [_]StyledLine{ .{ .text = "", .style = th.contentStyle() }, .{ .text = " zfin", .style = th.headerStyle() }, @@ -919,71 +919,71 @@ fn drawWelcomeScreen(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, wi .{ .text = " symbol::VTI,shares::100,open_date::2024-01-15,open_price::220.50", .style = th.mutedStyle() }, .{ .text = " symbol::AAPL,shares::50,open_date::2024-03-01,open_price::170.00", .style = th.mutedStyle() }, }; - try self.drawStyledContent(arena, buf, width, height, &welcome_lines); + try app.drawStyledContent(arena, buf, width, height, &welcome_lines); } /// Reload portfolio file from disk without re-fetching prices. /// Uses cached candle data to recompute summary. -pub fn reloadPortfolioFile(self: *App) void { +pub fn reloadPortfolioFile(app: *App) void { // Re-read the portfolio file - if (self.portfolio) |*pf| pf.deinit(); - self.portfolio = null; - if (self.portfolio_path) |path| { - const file_data = std.fs.cwd().readFileAlloc(self.allocator, path, 10 * 1024 * 1024) catch { - self.setStatus("Error reading portfolio file"); + if (app.portfolio) |*pf| pf.deinit(); + app.portfolio = null; + if (app.portfolio_path) |path| { + const file_data = std.fs.cwd().readFileAlloc(app.allocator, path, 10 * 1024 * 1024) catch { + app.setStatus("Error reading portfolio file"); return; }; - defer self.allocator.free(file_data); - if (zfin.cache.deserializePortfolio(self.allocator, file_data)) |pf| { - self.portfolio = pf; + defer app.allocator.free(file_data); + if (zfin.cache.deserializePortfolio(app.allocator, file_data)) |pf| { + app.portfolio = pf; } else |_| { - self.setStatus("Error parsing portfolio file"); + app.setStatus("Error parsing portfolio file"); return; } } else { - self.setStatus("No portfolio file to reload"); + app.setStatus("No portfolio file to reload"); return; } // Reload watchlist file too (if separate) - tui.freeWatchlist(self.allocator, self.watchlist); - self.watchlist = null; - if (self.watchlist_path) |path| { - self.watchlist = tui.loadWatchlist(self.allocator, path); + tui.freeWatchlist(app.allocator, app.watchlist); + app.watchlist = null; + if (app.watchlist_path) |path| { + app.watchlist = tui.loadWatchlist(app.allocator, path); } // Recompute summary using cached prices (no network) - self.freePortfolioSummary(); - self.expanded = [_]bool{false} ** 64; - self.cash_expanded = false; - self.illiquid_expanded = false; - self.cursor = 0; - self.scroll_offset = 0; - self.portfolio_rows.clearRetainingCapacity(); + app.freePortfolioSummary(); + app.expanded = [_]bool{false} ** 64; + app.cash_expanded = false; + app.illiquid_expanded = false; + app.cursor = 0; + app.scroll_offset = 0; + app.portfolio_rows.clearRetainingCapacity(); - const pf = self.portfolio orelse return; - const positions = pf.positions(self.allocator) catch { - self.setStatus("Error computing positions"); + const pf = app.portfolio orelse return; + const positions = pf.positions(app.allocator) catch { + app.setStatus("Error computing positions"); return; }; - defer self.allocator.free(positions); + defer app.allocator.free(positions); - var prices = std.StringHashMap(f64).init(self.allocator); + var prices = std.StringHashMap(f64).init(app.allocator); defer prices.deinit(); - const syms = pf.stockSymbols(self.allocator) catch { - self.setStatus("Error getting symbols"); + const syms = pf.stockSymbols(app.allocator) catch { + app.setStatus("Error getting symbols"); return; }; - defer self.allocator.free(syms); + defer app.allocator.free(syms); var latest_date: ?zfin.Date = null; var missing: usize = 0; for (syms) |sym| { // Cache only — no network - const candles_slice = self.svc.getCachedCandles(sym); + const candles_slice = app.svc.getCachedCandles(sym); if (candles_slice) |cs| { - defer self.allocator.free(cs); + defer app.allocator.free(cs); if (cs.len > 0) { prices.put(sym, cs[cs.len - 1].close) catch {}; const d = cs[cs.len - 1].date; @@ -993,50 +993,50 @@ pub fn reloadPortfolioFile(self: *App) void { missing += 1; } } - self.candle_last_date = latest_date; + app.candle_last_date = latest_date; // Build portfolio summary, candle map, and historical snapshots from cache - var pf_data = cli.buildPortfolioData(self.allocator, pf, positions, syms, &prices, self.svc) catch |err| switch (err) { + var pf_data = cli.buildPortfolioData(app.allocator, pf, positions, syms, &prices, app.svc) catch |err| switch (err) { error.NoAllocations => { - self.setStatus("No cached prices available"); + app.setStatus("No cached prices available"); return; }, error.SummaryFailed => { - self.setStatus("Error computing portfolio summary"); + app.setStatus("Error computing portfolio summary"); return; }, else => { - self.setStatus("Error building portfolio data"); + app.setStatus("Error building portfolio data"); return; }, }; - self.portfolio_summary = pf_data.summary; - self.historical_snapshots = pf_data.snapshots; + app.portfolio_summary = pf_data.summary; + app.historical_snapshots = pf_data.snapshots; { var it = pf_data.candle_map.valueIterator(); - while (it.next()) |v| self.allocator.free(v.*); + while (it.next()) |v| app.allocator.free(v.*); pf_data.candle_map.deinit(); } - sortPortfolioAllocations(self); - rebuildPortfolioRows(self); + sortPortfolioAllocations(app); + rebuildPortfolioRows(app); // Invalidate analysis data -- it holds pointers into old portfolio memory - if (self.analysis_result) |*ar| ar.deinit(self.allocator); - self.analysis_result = null; - self.analysis_loaded = false; + if (app.analysis_result) |*ar| ar.deinit(app.allocator); + app.analysis_result = null; + app.analysis_loaded = false; // If currently on the analysis tab, eagerly recompute so the user // doesn't see an error message before switching away and back. - if (self.active_tab == .analysis) { - self.loadAnalysisData(); + if (app.active_tab == .analysis) { + app.loadAnalysisData(); } if (missing > 0) { var warn_buf: [128]u8 = undefined; const warn_msg = std.fmt.bufPrint(&warn_buf, "Reloaded. {d} symbols missing cached prices", .{missing}) catch "Reloaded (some prices missing)"; - self.setStatus(warn_msg); + app.setStatus(warn_msg); } else { - self.setStatus("Portfolio reloaded from disk"); + app.setStatus("Portfolio reloaded from disk"); } } diff --git a/src/tui/quote_tab.zig b/src/tui/quote_tab.zig index 23bbbd7..5da28bb 100644 --- a/src/tui/quote_tab.zig +++ b/src/tui/quote_tab.zig @@ -14,40 +14,40 @@ const glyph = tui.glyph; /// Draw the quote tab content. Uses Kitty graphics for the chart when available, /// falling back to braille sparkline otherwise. -pub fn drawContent(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void { +pub fn drawContent(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void { const arena = ctx.arena; // Determine whether to use Kitty graphics - const use_kitty = switch (self.chart.config.mode) { + const use_kitty = switch (app.chart.config.mode) { .braille => false, .kitty => true, - .auto => if (self.vx_app) |va| va.vx.caps.kitty_graphics else false, + .auto => if (app.vx_app) |va| va.vx.caps.kitty_graphics else false, }; - if (use_kitty and self.candles != null and self.candles.?.len >= 40) { - drawWithKittyChart(self, ctx, buf, width, height) catch { + if (use_kitty and app.candles != null and app.candles.?.len >= 40) { + drawWithKittyChart(app, ctx, buf, width, height) catch { // On any failure, fall back to braille - try self.drawStyledContent(arena, buf, width, height, try buildStyledLines(self, arena)); + try app.drawStyledContent(arena, buf, width, height, try buildStyledLines(app, arena)); }; } else { // Fallback to styled lines with braille chart - try self.drawStyledContent(arena, buf, width, height, try buildStyledLines(self, arena)); + try app.drawStyledContent(arena, buf, width, height, try buildStyledLines(app, arena)); } } /// Draw quote tab using Kitty graphics protocol for the chart. -fn drawWithKittyChart(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void { +fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void { const arena = ctx.arena; - const th = self.theme; - const c = self.candles orelse return; + const th = app.theme; + const c = app.candles orelse return; // Build text header (symbol, price, change) — first few lines var lines: std.ArrayList(StyledLine) = .empty; try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); // Symbol + price header - if (self.quote) |q| { - const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2}", .{ self.symbol, q.close }); + if (app.quote) |q| { + const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2}", .{ app.symbol, q.close }); try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() }); if (q.previous_close > 0) { const change = q.close - q.previous_close; @@ -58,7 +58,7 @@ fn drawWithKittyChart(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell } } else if (c.len > 0) { const last = c[c.len - 1]; - const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2} (close)", .{ self.symbol, last.close }); + const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2} (close)", .{ app.symbol, last.close }); try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() }); if (c.len >= 2) { const prev_close = c[c.len - 2].close; @@ -80,7 +80,7 @@ fn drawWithKittyChart(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell const timeframes = [_]chart_mod.Timeframe{ .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" }; for (timeframes) |tf| { const lbl = tf.label(); - if (tf == self.chart.timeframe) { + if (tf == app.chart.timeframe) { tf_buf[tf_pos] = '['; tf_pos += 1; @memcpy(tf_buf[tf_pos..][0..lbl.len], lbl); @@ -101,7 +101,7 @@ fn drawWithKittyChart(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell const hint = " ([ ] to change)"; @memcpy(tf_buf[tf_pos..][0..hint.len], hint); tf_pos += hint.len; - self.chart.timeframe_row = lines.items.len; // track which row the timeframe line is on + app.chart.timeframe_row = lines.items.len; // track which row the timeframe line is on try lines.append(arena, .{ .text = try arena.dupe(u8, tf_buf[0..tf_pos]), .style = th.mutedStyle() }); } @@ -109,7 +109,7 @@ fn drawWithKittyChart(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell // Draw the text header const header_lines = try lines.toOwnedSlice(arena); - try self.drawStyledContent(arena, buf, width, height, header_lines); + try app.drawStyledContent(arena, buf, width, height, header_lines); // Calculate chart area (below the header, leaving room for details below) const header_rows: u16 = @intCast(@min(header_lines.len, height)); @@ -129,51 +129,51 @@ fn drawWithKittyChart(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell if (px_w < 100 or px_h < 100) return; // Apply resolution cap from chart config - const capped_w = @min(px_w, self.chart.config.max_width); - const capped_h = @min(px_h, self.chart.config.max_height); + const capped_w = @min(px_w, app.chart.config.max_width); + const capped_h = @min(px_h, app.chart.config.max_height); // Check if we need to re-render the chart image - const symbol_changed = self.chart.symbol_len != self.symbol.len or - !std.mem.eql(u8, self.chart.symbol[0..self.chart.symbol_len], self.symbol); - const tf_changed = self.chart.timeframe_rendered == null or self.chart.timeframe_rendered.? != self.chart.timeframe; + const symbol_changed = app.chart.symbol_len != app.symbol.len or + !std.mem.eql(u8, app.chart.symbol[0..app.chart.symbol_len], app.symbol); + const tf_changed = app.chart.timeframe_rendered == null or app.chart.timeframe_rendered.? != app.chart.timeframe; - if (self.chart.dirty or symbol_changed or tf_changed) { + if (app.chart.dirty or symbol_changed or tf_changed) { // Free old image - if (self.chart.image_id) |old_id| { - if (self.vx_app) |va| { + if (app.chart.image_id) |old_id| { + if (app.vx_app) |va| { va.vx.freeImage(va.tty.writer(), old_id); } - self.chart.image_id = null; + app.chart.image_id = null; } // Render and transmit — use the app's main allocator, NOT the arena, // because z2d allocates large pixel buffers that would bloat the arena. - if (self.vx_app) |va| { + if (app.vx_app) |va| { const chart_result = chart_mod.renderChart( - self.allocator, + app.allocator, c, - self.chart.timeframe, + app.chart.timeframe, capped_w, capped_h, th, ) catch |err| { - self.chart.dirty = false; + app.chart.dirty = false; var err_buf: [128]u8 = undefined; const msg = std.fmt.bufPrint(&err_buf, "Chart render failed: {s}", .{@errorName(err)}) catch "Chart render failed"; - self.setStatus(msg); + app.setStatus(msg); return; }; - defer self.allocator.free(chart_result.rgb_data); + defer app.allocator.free(chart_result.rgb_data); // Base64-encode and transmit raw RGB data directly via Kitty protocol. // This avoids the PNG encode → file write → file read → PNG decode roundtrip. const base64_enc = std.base64.standard.Encoder; - const b64_buf = self.allocator.alloc(u8, base64_enc.calcSize(chart_result.rgb_data.len)) catch { - self.chart.dirty = false; - self.setStatus("Chart: base64 alloc failed"); + const b64_buf = app.allocator.alloc(u8, base64_enc.calcSize(chart_result.rgb_data.len)) catch { + app.chart.dirty = false; + app.setStatus("Chart: base64 alloc failed"); return; }; - defer self.allocator.free(b64_buf); + defer app.allocator.free(b64_buf); const encoded = base64_enc.encode(b64_buf, chart_result.rgb_data); const img = va.vx.transmitPreEncodedImage( @@ -183,31 +183,31 @@ fn drawWithKittyChart(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell chart_result.height, .rgb, ) catch |err| { - self.chart.dirty = false; + app.chart.dirty = false; var err_buf: [128]u8 = undefined; const msg = std.fmt.bufPrint(&err_buf, "Image transmit failed: {s}", .{@errorName(err)}) catch "Image transmit failed"; - self.setStatus(msg); + app.setStatus(msg); return; }; - self.chart.image_id = img.id; - self.chart.image_width = @intCast(chart_cols); - self.chart.image_height = chart_rows; + app.chart.image_id = img.id; + app.chart.image_width = @intCast(chart_cols); + app.chart.image_height = chart_rows; // Track what we rendered - const sym_len = @min(self.symbol.len, 16); - @memcpy(self.chart.symbol[0..sym_len], self.symbol[0..sym_len]); - self.chart.symbol_len = sym_len; - self.chart.timeframe_rendered = self.chart.timeframe; - self.chart.price_min = chart_result.price_min; - self.chart.price_max = chart_result.price_max; - self.chart.rsi_latest = chart_result.rsi_latest; - self.chart.dirty = false; + const sym_len = @min(app.symbol.len, 16); + @memcpy(app.chart.symbol[0..sym_len], app.symbol[0..sym_len]); + app.chart.symbol_len = sym_len; + app.chart.timeframe_rendered = app.chart.timeframe; + app.chart.price_min = chart_result.price_min; + app.chart.price_max = chart_result.price_max; + app.chart.rsi_latest = chart_result.rsi_latest; + app.chart.dirty = false; } } // Place the image in the cell buffer - if (self.chart.image_id) |img_id| { + if (app.chart.image_id) |img_id| { // Place image at the first cell of the chart area const chart_row_start: usize = header_rows; const chart_col_start: usize = 1; // 1 col left margin @@ -220,8 +220,8 @@ fn drawWithKittyChart(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell .img_id = img_id, .options = .{ .size = .{ - .rows = self.chart.image_height, - .cols = self.chart.image_width, + .rows = app.chart.image_height, + .cols = app.chart.image_width, }, .scale = .contain, }, @@ -232,17 +232,17 @@ fn drawWithKittyChart(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell // ── Axis labels (terminal text in the right margin) ─────────── // The chart image uses layout fractions: price=72%, gap=8%, RSI=20% // Map these to terminal rows to position labels. - const img_rows = self.chart.image_height; - const label_col: usize = @as(usize, chart_col_start) + @as(usize, self.chart.image_width) + 1; + const img_rows = app.chart.image_height; + const label_col: usize = @as(usize, chart_col_start) + @as(usize, app.chart.image_width) + 1; const label_style = th.mutedStyle(); - if (label_col + 8 <= width and img_rows >= 4 and self.chart.price_max > self.chart.price_min) { + if (label_col + 8 <= width and img_rows >= 4 and app.chart.price_max > app.chart.price_min) { // Price axis labels — evenly spaced across the price panel (top 72%) const price_panel_rows = @as(f64, @floatFromInt(img_rows)) * 0.72; const n_price_labels: usize = 5; for (0..n_price_labels) |i| { const frac = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n_price_labels - 1)); - const price_val = self.chart.price_max - frac * (self.chart.price_max - self.chart.price_min); + const price_val = app.chart.price_max - frac * (app.chart.price_max - app.chart.price_min); const row_f = @as(f64, @floatFromInt(chart_row_start)) + frac * price_panel_rows; const row: usize = @intFromFloat(@round(row_f)); if (row >= height) continue; @@ -290,58 +290,58 @@ fn drawWithKittyChart(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell } // Render quote details below the chart image as styled text - const detail_start_row = header_rows + self.chart.image_height; + const detail_start_row = header_rows + app.chart.image_height; if (detail_start_row + 8 < height) { var detail_lines: std.ArrayList(StyledLine) = .empty; try detail_lines.append(arena, .{ .text = "", .style = th.contentStyle() }); const latest = c[c.len - 1]; - const quote_data = self.quote; + const quote_data = app.quote; const price = if (quote_data) |q| q.close else latest.close; const prev_close = if (quote_data) |q| q.previous_close else if (c.len >= 2) c[c.len - 2].close else @as(f64, 0); - try buildDetailColumns(self, arena, &detail_lines, latest, quote_data, price, prev_close); + try buildDetailColumns(app, arena, &detail_lines, latest, quote_data, price, prev_close); // Write detail lines into the buffer below the image const detail_buf_start = detail_start_row * @as(usize, width); const remaining_height = height - @as(u16, @intCast(detail_start_row)); const detail_slice = try detail_lines.toOwnedSlice(arena); if (detail_buf_start < buf.len) { - try self.drawStyledContent(arena, buf[detail_buf_start..], width, remaining_height, detail_slice); + try app.drawStyledContent(arena, buf[detail_buf_start..], width, remaining_height, detail_slice); } } } } -fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { - const th = self.theme; +fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine { + const th = app.theme; var lines: std.ArrayList(StyledLine) = .empty; try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); - if (self.symbol.len == 0) { + if (app.symbol.len == 0) { try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() }); return lines.toOwnedSlice(arena); } var ago_buf: [16]u8 = undefined; - if (self.quote != null and self.quote_timestamp > 0) { - const ago_str = fmt.fmtTimeAgo(&ago_buf, self.quote_timestamp); - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s} (live, ~15 min delay, refreshed {s})", .{ self.symbol, ago_str }), .style = th.headerStyle() }); - } else if (self.candle_last_date) |d| { + if (app.quote != null and app.quote_timestamp > 0) { + const ago_str = fmt.fmtTimeAgo(&ago_buf, app.quote_timestamp); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s} (live, ~15 min delay, refreshed {s})", .{ app.symbol, ago_str }), .style = th.headerStyle() }); + } else if (app.candle_last_date) |d| { var cdate_buf: [10]u8 = undefined; - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s} (as of close on {s})", .{ self.symbol, d.format(&cdate_buf) }), .style = th.headerStyle() }); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s} (as of close on {s})", .{ app.symbol, d.format(&cdate_buf) }), .style = th.headerStyle() }); } else { - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s}", .{self.symbol}), .style = th.headerStyle() }); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s}", .{app.symbol}), .style = th.headerStyle() }); } try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); - if (self.candles == null and !self.perf_loaded) self.loadPerfData(); + if (app.candles == null and !app.perf_loaded) app.loadPerfData(); // Use stored real-time quote if available (fetched on manual refresh) - const quote_data = self.quote; + const quote_data = app.quote; - const c = self.candles orelse { + const c = app.candles orelse { if (quote_data) |q| { // No candle data but have a quote - show it var qclose_buf: [24]u8 = undefined; @@ -353,7 +353,7 @@ fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { } return lines.toOwnedSlice(arena); } - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " No data. Run: zfin perf {s}", .{self.symbol}), .style = th.mutedStyle() }); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " No data. Run: zfin perf {s}", .{app.symbol}), .style = th.mutedStyle() }); return lines.toOwnedSlice(arena); }; if (c.len == 0) { @@ -366,7 +366,7 @@ fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { const prev_close = if (quote_data) |q| q.previous_close else if (c.len >= 2) c[c.len - 2].close else @as(f64, 0); const latest = c[c.len - 1]; - try buildDetailColumns(self, arena, &lines, latest, quote_data, price, prev_close); + try buildDetailColumns(app, arena, &lines, latest, quote_data, price, prev_close); // Braille sparkline chart of recent 60 trading days try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); @@ -404,18 +404,18 @@ const Column = struct { }; } - fn add(self: *Column, arena: std.mem.Allocator, text: []const u8, style: vaxis.Style) !void { - try self.texts.append(arena, text); - try self.styles.append(arena, style); + fn add(app: *Column, arena: std.mem.Allocator, text: []const u8, style: vaxis.Style) !void { + try app.texts.append(arena, text); + try app.styles.append(arena, style); } - fn len(self: *const Column) usize { - return self.texts.items.len; + fn len(app: *const Column) usize { + return app.texts.items.len; } }; fn buildDetailColumns( - self: *App, + app: *App, arena: std.mem.Allocator, lines: *std.ArrayList(StyledLine), latest: zfin.Candle, @@ -423,7 +423,7 @@ fn buildDetailColumns( price: f64, prev_close: f64, ) !void { - const th = self.theme; + const th = app.theme; var date_buf: [10]u8 = undefined; var close_buf: [24]u8 = undefined; var vol_buf: [32]u8 = undefined; @@ -453,7 +453,7 @@ fn buildDetailColumns( var col4 = Column.init(); // Top holdings col4.width = 30; - if (self.etf_profile) |profile| { + if (app.etf_profile) |profile| { // Col 2: ETF key stats try col2.add(arena, "ETF Profile", th.headerStyle()); if (profile.expense_ratio) |er| {