From 3ff42591ad7d472d3712b63490a151eefacc0ac1 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Sat, 16 May 2026 12:01:53 -0700 Subject: [PATCH] complete tui.zig architectural refactor --- src/tui.zig | 568 +++++++++++++++++++----------------- src/tui/analysis_tab.zig | 5 +- src/tui/earnings_tab.zig | 3 +- src/tui/history_tab.zig | 4 + src/tui/options_tab.zig | 20 +- src/tui/performance_tab.zig | 3 +- src/tui/portfolio_tab.zig | 380 +++++++++++------------- src/tui/projections_tab.zig | 26 +- src/tui/quote_tab.zig | 72 ++++- src/tui/tab_framework.zig | 48 +++ 10 files changed, 624 insertions(+), 505 deletions(-) diff --git a/src/tui.zig b/src/tui.zig index d90ece3..101b153 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -11,11 +11,12 @@ const chart = @import("tui/chart.zig"); const input_buffer = @import("tui/input_buffer.zig"); /// Single source of truth for tab modules. Each entry is the -/// imported tab module; the field name is the tab's tag (must match -/// the `Tab` enum variant). `TabStates` is derived from this -/// registry at comptime — adding a new tab is a single edit here -/// (plus the matching `Tab` enum variant + label, until those are -/// derived too). +/// imported tab module; the field name is the tab's tag. The +/// `Tab` enum, `TabStates`, `tab_labels`, and the `tabs` slice +/// are all derived from this registry at comptime — adding a +/// new tab is a single edit (append a field here, declare the +/// tab's `tab` namespace + `State`, and everything else flows +/// from `pub const label` on the tab module). const tab_modules = .{ .portfolio = @import("tui/portfolio_tab.zig"), .quote = @import("tui/quote_tab.zig"), @@ -239,147 +240,6 @@ pub fn buildHelpLines( return lines.toOwnedSlice(arena); } -// ── Tab-specific types ─────────────────────────────────────────── -// These logically belong to individual tab files, but live here because -// App's struct fields reference them and Zig requires field types to be -// resolved in the same struct definition. - -pub const PortfolioSortField = enum { - symbol, - shares, - avg_cost, - price, - market_value, - gain_loss, - weight, - account, - - pub fn label(self: PortfolioSortField) []const u8 { - return switch (self) { - .symbol => "Symbol", - .shares => "Shares", - .avg_cost => "Avg Cost", - .price => "Price", - .market_value => "Market Value", - .gain_loss => "Gain/Loss", - .weight => "Weight", - .account => "Account", - }; - } - - pub fn next(self: PortfolioSortField) ?PortfolioSortField { - const fields = std.meta.fields(PortfolioSortField); - const idx: usize = @intFromEnum(self); - if (idx + 1 >= fields.len) return null; - return @enumFromInt(idx + 1); - } - - pub fn prev(self: PortfolioSortField) ?PortfolioSortField { - const idx: usize = @intFromEnum(self); - if (idx == 0) return null; - return @enumFromInt(idx - 1); - } -}; - -pub const SortDirection = enum { - asc, - desc, - - pub fn flip(self: SortDirection) SortDirection { - return if (self == .asc) .desc else .asc; - } - - pub fn indicator(self: SortDirection) []const u8 { - return if (self == .asc) "▲" else "▼"; - } -}; - -pub const PortfolioRow = struct { - kind: Kind, - symbol: []const u8, - /// For position rows: index into allocations; for lot rows: lot data. - pos_idx: usize = 0, - lot: ?zfin.Lot = null, - /// Number of lots for this symbol (set on position rows) - lot_count: usize = 0, - /// DRIP summary data (for drip_summary rows) - drip_is_lt: bool = false, // true = LT summary, false = ST summary - drip_lot_count: usize = 0, - drip_shares: f64 = 0, - drip_avg_cost: f64 = 0, - drip_date_first: ?zfin.Date = null, - drip_date_last: ?zfin.Date = null, - /// Pre-formatted text from view model (options and CDs) - prepared_text: ?[]const u8 = null, - /// Semantic styles from view model - row_style: fmt.StyleIntent = .normal, - premium_style: fmt.StyleIntent = .normal, - /// Column offset for premium alt-style coloring (options only) - premium_col_start: usize = 0, - - const Kind = enum { position, lot, watchlist, section_header, option_row, cd_row, cash_row, cash_total, illiquid_row, illiquid_total, drip_summary }; -}; - -pub const OptionsRowKind = enum { expiration, calls_header, puts_header, call, put }; - -pub const OptionsRow = struct { - kind: OptionsRowKind, - exp_idx: usize = 0, // index into options_data chains - contract: ?zfin.OptionContract = null, -}; - -pub const ChartState = struct { - timeframe: chart.Timeframe = .@"1Y", - image_id: ?u32 = null, // currently transmitted Kitty image ID - image_width: u16 = 0, // image width in cells - image_height: u16 = 0, // image height in cells - symbol: [16]u8 = undefined, // symbol the chart was rendered for - symbol_len: usize = 0, - timeframe_rendered: ?chart.Timeframe = null, // timeframe the chart was rendered for - timeframe_row: ?usize = null, // screen row of the timeframe selector (for mouse clicks) - dirty: bool = true, // needs re-render - price_min: f64 = 0, - price_max: f64 = 0, - rsi_latest: ?f64 = null, - - // Cached indicator data (persists across frames to avoid recomputation) - cached_indicators: ?chart.CachedIndicators = null, - cache_candle_count: usize = 0, // candle count when cache was computed - cache_timeframe: ?chart.Timeframe = null, // timeframe when cache was computed - cache_last_close: f64 = 0, // last candle's close when cache was computed - - /// Free cached indicator memory. - pub fn freeCache(self: *ChartState, alloc: std.mem.Allocator) void { - if (self.cached_indicators) |*cache| { - cache.deinit(alloc); - self.cached_indicators = null; - } - self.cache_candle_count = 0; - self.cache_timeframe = null; - self.cache_last_close = 0; - } - - /// Check if cache is valid for the given candle data and timeframe. - pub fn isCacheValid(self: *const ChartState, candles: []const zfin.Candle, timeframe: chart.Timeframe) bool { - if (self.cached_indicators == null) return false; - if (self.cache_timeframe == null or self.cache_timeframe.? != timeframe) return false; - - // Slice candles to timeframe (same logic as renderChart) - const max_days = timeframe.tradingDays(); - const n = @min(candles.len, max_days); - const data = candles[candles.len - n ..]; - - if (data.len != self.cache_candle_count) return false; - if (data.len == 0) return false; - - // Check if last close changed (detects data refresh) - const last_close = data[data.len - 1].close; - if (@abs(last_close - self.cache_last_close) > 0.0001) return false; - - return true; - } -}; - /// Per-tab state, owned by `App` and accessed as `app.states.`. /// /// Per-tab private state aggregator, derived at comptime from the @@ -587,14 +447,21 @@ pub const PortfolioData = struct { /// (max of each symbol's last cached candle date). Drives the /// "as of close on YYYY-MM-DD" line under the portfolio totals. /// Computed as a side effect of the portfolio-prices loop in - /// `tab_modules.portfolio.loadPortfolioData`; null when no symbols have + /// `App.ensurePortfolioDataLoaded`; null when no symbols have /// cached candles. latest_quote_date: ?zfin.Date = null, + /// Prices fetched before the TUI started (with stderr + /// progress). Consumed by the first + /// `App.ensurePortfolioDataLoaded` call to skip redundant + /// network round-trips on startup. Owned here; freed after + /// first consumption. + prefetched_prices: ?std.StringHashMap(f64) = null, pub fn deinit(self: *PortfolioData, allocator: std.mem.Allocator) void { if (self.summary) |*s| s.deinit(allocator); if (self.account_map) |*am| am.deinit(); if (self.watchlist_prices) |*wp| wp.deinit(); + if (self.prefetched_prices) |*pp| pp.deinit(); if (self.file) |*pf| pf.deinit(); self.* = .{}; } @@ -613,10 +480,9 @@ pub const PortfolioData = struct { pub const App = struct { allocator: std.mem.Allocator, io: std.Io, - /// Per-tab private state. See `TabStates` above. Tabs that have - /// migrated to the framework own their fields under - /// `app.states.`; tabs not yet migrated still have their - /// fields directly on App. + /// Per-tab private state. See `TabStates` above. Each tab + /// owns its UI state under `app.states.` — the field + /// name matches the `tab_modules` registry tag. states: TabStates = .{}, /// Per-symbol shared data (candles, dividends, trailing returns, /// ETF profile). See `SymbolData` above. Cleared in @@ -1095,22 +961,6 @@ pub const App = struct { self.portfolio.account_map = self.svc.loadAccountMap(ppath); } - /// Set or clear the account filter. Owns the string via allocator. - pub fn setAccountFilter(self: *App, name: ?[]const u8) void { - 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.states.portfolio.account_filter = self.allocator.dupe(u8, n) catch null; - if (self.portfolio.file) |pf| { - self.states.portfolio.filtered_positions = pf.positionsForAccount(self.today, self.allocator, n) catch null; - } - } else { - self.states.portfolio.account_filter = null; - } - } - fn handleNormalKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void { // Ctrl+L: full screen redraw (standard TUI convention, not configurable) if (key.codepoint == 'l' and key.mods.ctrl) { @@ -1211,7 +1061,7 @@ pub const App = struct { return ctx.consumeAndRedraw(); }, .reload_portfolio => { - self.reloadPortfolioFile(); + tab_modules.portfolio.reloadPortfolioFile(&self.states.portfolio, self); return ctx.consumeAndRedraw(); }, } @@ -1349,8 +1199,225 @@ pub const App = struct { self.dispatchTry("activate", .{}); } - pub fn loadPortfolioData(self: *App) void { - tab_modules.portfolio.loadPortfolioData(&self.states.portfolio, self); + /// Free the cached portfolio summary on `app.portfolio`. Used + /// before re-fetching live prices (the summary is recomputed + /// from the new prices) and from `reload` to drop stale state. + /// `app.portfolio` is App-owned shared state — see + /// `PortfolioData` — so cleanup belongs here. + /// Ensure App-level portfolio data (`app.portfolio.summary`, + /// `.historical_snapshots`, `.watchlist_prices`, + /// `.latest_quote_date`) is populated. Idempotent — checks + /// `app.portfolio.loaded` and returns immediately if so. + /// + /// Called by tabs that need portfolio data (portfolio, + /// analysis, history, projections). Each tab's `activate` + /// calls this; it doesn't touch any tab's UI state. The + /// portfolio tab's `activate` does its own UI setup + /// (sortAllocations, buildAccountList, rebuildRows) AFTER + /// this returns. + /// + /// On first call, prefers `app.portfolio.prefetched_prices` + /// (populated before TUI startup); on subsequent calls + /// (after refresh has cleared `loaded`), fetches live via + /// `svc.loadPrices`. + /// + /// On any error path, sets a status message and returns + /// early. Callers are not expected to inspect a result — + /// they read `app.portfolio.summary` after returning and + /// branch on `null`. + pub fn ensurePortfolioDataLoaded(self: *App) void { + if (self.portfolio.loaded) return; + self.portfolio.loaded = true; + self.freePortfolioSummary(); + + const pf = self.portfolio.file orelse return; + + const positions = pf.positions(self.today, self.allocator) catch { + self.setStatus("Error computing positions"); + return; + }; + defer self.allocator.free(positions); + + var prices = std.StringHashMap(f64).init(self.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"); + return; + }; + defer self.allocator.free(syms); + + var latest_date: ?zfin.Date = null; + var fail_count: usize = 0; + var fetch_count: usize = 0; + var stale_count: usize = 0; + var failed_syms: [8][]const u8 = undefined; + + if (self.portfolio.prefetched_prices) |*pp| { + // Use pre-fetched prices from before TUI started (first load only) + for (syms) |sym| { + if (pp.get(sym)) |price| { + prices.put(sym, price) catch {}; + } + } + + // Extract watchlist prices + if (self.portfolio.watchlist_prices) |*wp| wp.clearRetainingCapacity() else { + self.portfolio.watchlist_prices = std.StringHashMap(f64).init(self.allocator); + } + var wp = &(self.portfolio.watchlist_prices.?); + var pp_iter = pp.iterator(); + while (pp_iter.next()) |entry| { + if (!prices.contains(entry.key_ptr.*)) { + wp.put(entry.key_ptr.*, entry.value_ptr.*) catch {}; + } + } + + pp.deinit(); + self.portfolio.prefetched_prices = null; + } else { + // Live fetch (refresh path) — fetch watchlist first, then stock prices + if (self.portfolio.watchlist_prices) |*wp| wp.clearRetainingCapacity() else { + self.portfolio.watchlist_prices = std.StringHashMap(f64).init(self.allocator); + } + var wp = &(self.portfolio.watchlist_prices.?); + if (self.watchlist) |wl| { + for (wl) |sym| { + const result = self.svc.getCandles(sym) catch continue; + defer result.deinit(); + if (result.data.len > 0) { + wp.put(sym, result.data[result.data.len - 1].close) catch {}; + } + } + } + for (pf.lots) |lot| { + if (lot.security_type == .watch) { + const sym = lot.priceSymbol(); + const result = self.svc.getCandles(sym) catch continue; + defer result.deinit(); + if (result.data.len > 0) { + wp.put(sym, result.data[result.data.len - 1].close) catch {}; + } + } + } + + // Fetch stock prices with TUI status-bar progress + const TuiProgress = struct { + app: *App, + failed: *[8][]const u8, + fail_n: usize = 0, + + fn onProgress(ctx: *anyopaque, _: usize, _: usize, symbol: []const u8, status: zfin.DataService.SymbolStatus) void { + const s: *@This() = @ptrCast(@alignCast(ctx)); + switch (status) { + .fetching => { + var buf: [64]u8 = undefined; + const msg = std.fmt.bufPrint(&buf, "Loading {s}...", .{symbol}) catch "Loading..."; + s.app.setStatus(msg); + }, + .failed, .failed_used_stale => { + if (s.fail_n < s.failed.len) { + s.failed[s.fail_n] = symbol; + s.fail_n += 1; + } + }, + else => {}, + } + } + + fn callback(s: *@This()) zfin.DataService.ProgressCallback { + return .{ + .context = @ptrCast(s), + .on_progress = onProgress, + }; + } + }; + var tui_progress = TuiProgress{ .app = self, .failed = &failed_syms }; + const load_result = self.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.portfolio.latest_quote_date = latest_date; + + // Build portfolio summary, candle map, and historical snapshots + var pf_data = cli.buildPortfolioData(self.allocator, pf, positions, syms, &prices, self.svc, self.today) catch |err| switch (err) { + error.NoAllocations => { + self.setStatus("No cached prices. Run: zfin perf first"); + return; + }, + error.SummaryFailed => { + self.setStatus("Error computing portfolio summary"); + return; + }, + else => { + self.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.portfolio.historical_snapshots = pf_data.snapshots; + { + var it = pf_data.candle_map.valueIterator(); + while (it.next()) |v| self.allocator.free(v.*); + pf_data.candle_map.deinit(); + } + + // Show warning if any securities failed to load + if (fail_count > 0) { + var warn_buf: [256]u8 = undefined; + if (fail_count <= 3) { + // Show actual symbol names for easier debugging + var sym_buf: [128]u8 = undefined; + var sym_len: usize = 0; + const show = @min(fail_count, failed_syms.len); + for (0..show) |fi| { + if (sym_len > 0) { + if (sym_len + 2 < sym_buf.len) { + sym_buf[sym_len] = ','; + sym_buf[sym_len + 1] = ' '; + sym_len += 2; + } + } + const s = failed_syms[fi]; + const copy_len = @min(s.len, sym_buf.len - sym_len); + @memcpy(sym_buf[sym_len..][0..copy_len], s[0..copy_len]); + sym_len += copy_len; + } + 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); + } 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); + } + } 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); + } 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); + } + } + } 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); + } else { + // Empty status — App's getStatus() will fall back to the + // dynamic default hint composed from the active tab's + // status_hints + global keys. + self.setStatus(""); + } + } + + pub fn freePortfolioSummary(self: *App) void { + if (self.portfolio.summary) |*s| s.deinit(self.allocator); + self.portfolio.summary = null; } pub fn setStatus(self: *App, msg: []const u8) void { @@ -1359,6 +1426,27 @@ pub const App = struct { self.status_len = len; } + /// Cell pixel size for the active terminal, used by tabs that + /// render bitmap charts via the Kitty graphics protocol. Falls + /// back to (8, 16) when vaxis hasn't reported pixel dimensions + /// yet (terminal didn't answer the size query, or we're early + /// in startup before the first frame). + /// + /// Returns the dimensions vaxis itself would put in + /// `DrawContext.cell_size`, so tabs don't have to thread `ctx` + /// through their `drawContent` hook just to size an image. + pub fn cellPixelSize(self: *const App) struct { width: u32, height: u32 } { + const va = self.vx_app orelse return .{ .width = 8, .height = 16 }; + const screen = &va.vx.screen; + if (screen.width == 0 or screen.height == 0) return .{ .width = 8, .height = 16 }; + const w = screen.width_pix / screen.width; + const h = screen.height_pix / screen.height; + return .{ + .width = if (w > 0) @as(u32, w) else 8, + .height = if (h > 0) @as(u32, h) else 16, + }; + } + /// Returns the current status message. When no message has been /// set, builds a dynamic default hint composed from a small set /// of always-shown global keys plus the active tab's @@ -1413,11 +1501,6 @@ pub const App = struct { return formatStatusHint(arena, fragments.items); } - pub fn freePortfolioSummary(self: *App) void { - if (self.portfolio.summary) |*s| s.deinit(self.allocator); - self.portfolio.summary = null; - } - fn deinitData(self: *App) void { self.symbol_data.deinit(self.allocator); tab_modules.earnings.tab.deinit(&self.states.earnings, self); @@ -1529,37 +1612,39 @@ pub const App = struct { if (self.mode == .help) { try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildHelpStyledLines(ctx.arena)); } else { - switch (self.active_tab) { - .portfolio => try self.drawPortfolioContent(ctx.arena, buf, width, height), - .quote => try self.drawQuoteContent(ctx, buf, width, height), - .performance => { - const lines = try self.buildPerfStyledLines(ctx.arena); - const start = @min(self.scroll_offset, if (lines.len > 0) lines.len - 1 else 0); - try self.drawStyledContent(ctx.arena, buf, width, height, lines[start..]); - }, - .options => try self.drawOptionsContent(ctx.arena, buf, width, height), - .earnings => { - const lines = try self.buildEarningsStyledLines(ctx.arena); - const start = @min(self.scroll_offset, if (lines.len > 0) lines.len - 1 else 0); - try self.drawStyledContent(ctx.arena, buf, width, height, lines[start..]); - }, - .analysis => { - const lines = try self.buildAnalysisStyledLines(ctx.arena); - const start = @min(self.scroll_offset, if (lines.len > 0) lines.len - 1 else 0); - try self.drawStyledContent(ctx.arena, buf, width, height, lines[start..]); - }, - .history => { - const lines = try self.buildHistoryStyledLines(ctx.arena); - const start = @min(self.scroll_offset, if (lines.len > 0) lines.len - 1 else 0); - try self.drawStyledContent(ctx.arena, buf, width, height, lines[start..]); - }, - .projections => try tab_modules.projections.drawContent(&self.states.projections, self, ctx, buf, width, height), - } + try self.dispatchDraw(ctx.arena, buf, width, height); } return .{ .size = .{ .width = width, .height = height }, .widget = self.widget(), .buffer = buf, .children = &.{} }; } + /// Dispatch the active tab's draw hook. Each tab declares + /// EXACTLY ONE of `buildStyledLines` (line-list rendering; + /// App handles scroll clamping + cell rendering) or + /// `drawContent` (direct buffer; for layouts that don't fit + /// the line-list shape, e.g. Kitty-graphics chart frames). + /// The framework validator (in `tab_framework.zig`) enforces + /// the exactly-one rule at compile time, so the + /// `@hasDecl` branches below are total. + fn dispatchDraw(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { + inline for (std.meta.fields(@TypeOf(tab_modules))) |field| { + if (std.mem.eql(u8, field.name, @tagName(self.active_tab))) { + const Module = @field(tab_modules, field.name); + const state_ptr = &@field(self.states, field.name); + + if (@hasDecl(Module, "drawContent")) { + return Module.drawContent(state_ptr, self, arena, buf, width, height); + } + // buildStyledLines — by the validator's exactly-one + // rule, this branch must be reached when drawContent + // isn't declared. + const lines = try Module.buildStyledLines(state_ptr, self, arena); + const start = @min(self.scroll_offset, if (lines.len > 0) lines.len - 1 else 0); + return self.drawStyledContent(arena, buf, width, height, lines[start..]); + } + } + } + pub fn drawStyledContent(_: *App, _: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16, lines: []const StyledLine) !void { for (lines, 0..) |line, row| { if (row >= height) break; @@ -1695,48 +1780,6 @@ pub const App = struct { return null; } - // ── Portfolio content ───────────────────────────────────────── - - fn drawPortfolioContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { - return tab_modules.portfolio.drawContent(&self.states.portfolio, self, arena, buf, width, height); - } - - // ── Options content (with cursor/scroll) ───────────────────── - - fn drawOptionsContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { - const styled_lines = try tab_modules.options.buildStyledLines(self, arena); - const start = @min(self.scroll_offset, if (styled_lines.len > 0) styled_lines.len - 1 else 0); - try self.drawStyledContent(arena, buf, width, height, styled_lines[start..]); - } - - // ── Quote tab ──────────────────────────────────────────────── - - fn drawQuoteContent(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void { - return tab_modules.quote.drawContent(self, ctx, buf, width, height); - } - - // ── Performance tab ────────────────────────────────────────── - - fn buildPerfStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { - return tab_modules.performance.buildStyledLines(self, arena); - } - - // ── Earnings tab ───────────────────────────────────────────── - - fn buildEarningsStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { - return tab_modules.earnings.buildStyledLines(self, arena); - } - - // ── Analysis tab ──────────────────────────────────────────── - - fn buildAnalysisStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { - return tab_modules.analysis.buildStyledLines(self, arena); - } - - fn buildHistoryStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { - return tab_modules.history.buildStyledLines(&self.states.history, self, arena); - } - // ── Help ───────────────────────────────────────────────────── fn buildHelpStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { @@ -2208,16 +2251,18 @@ pub fn run( false, // force_refresh true, // color ); - app_inst.states.portfolio.prefetched_prices = load_result.prices; + app_inst.portfolio.prefetched_prices = load_result.prices; } - // Eagerly compute PortfolioData so the history-tab's live - // pseudo-row + compare-to-live-now works from first render, - // without requiring the user to visit the portfolio tab - // first. Cheap (pure compute + cache reads) once prices are - // already in hand. + // Pre-load PortfolioData while the terminal is still in + // normal mode — `loadPrices` emits stderr progress that + // would be invisible after vaxis takes over the screen. + // Each tab that needs the data also calls + // `ensurePortfolioDataLoaded` from its `activate` + // (idempotent), so this is a UX optimization, not a + // correctness requirement. if (app_inst.portfolio.file != null) { - tab_modules.portfolio.loadPortfolioData(&app_inst.states.portfolio, app_inst); + app_inst.ensurePortfolioDataLoaded(); } } @@ -2291,29 +2336,6 @@ test "glyph non-ASCII returns space" { try testing.expectEqualStrings(" ", glyph(200)); } -test "PortfolioSortField next/prev" { - // next from first field - try testing.expectEqual(PortfolioSortField.shares, PortfolioSortField.symbol.next().?); - // next from last field returns null - try testing.expectEqual(@as(?PortfolioSortField, null), PortfolioSortField.account.next()); - // prev from first returns null - try testing.expectEqual(@as(?PortfolioSortField, null), PortfolioSortField.symbol.prev()); - // prev from last - try testing.expectEqual(PortfolioSortField.weight, PortfolioSortField.account.prev().?); -} - -test "PortfolioSortField label" { - try testing.expectEqualStrings("Symbol", PortfolioSortField.symbol.label()); - try testing.expectEqualStrings("Market Value", PortfolioSortField.market_value.label()); -} - -test "SortDirection flip and indicator" { - try testing.expectEqual(SortDirection.desc, SortDirection.asc.flip()); - try testing.expectEqual(SortDirection.asc, SortDirection.desc.flip()); - try testing.expectEqualStrings("\xe2\x96\xb2", SortDirection.asc.indicator()); // ▲ - try testing.expectEqualStrings("\xe2\x96\xbc", SortDirection.desc.indicator()); // ▼ -} - test "Tab label" { try testing.expectEqualStrings(" 1:Portfolio ", tabLabel(.portfolio)); try testing.expectEqualStrings(" 6:Analysis ", tabLabel(.analysis)); diff --git a/src/tui/analysis_tab.zig b/src/tui/analysis_tab.zig index f292b87..eee036b 100644 --- a/src/tui/analysis_tab.zig +++ b/src/tui/analysis_tab.zig @@ -105,7 +105,7 @@ fn loadData(state: *State, app: *App) void { state.loaded = true; // Ensure portfolio is loaded first - if (!app.portfolio.loaded) app.loadPortfolioData(); + app.ensurePortfolioDataLoaded(); const pf = app.portfolio.file orelse return; const summary = app.portfolio.summary orelse return; @@ -162,8 +162,7 @@ fn loadDataFinish(state: *State, app: *App, pf: zfin.Portfolio, summary: zfin.va // ── Rendering ───────────────────────────────────────────────── -pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine { - const state = &app.states.analysis; +pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine { // Compute equity/fixed split from classification + portfolio var stock_pct: f64 = 0; var bond_pct: f64 = 0; diff --git a/src/tui/earnings_tab.zig b/src/tui/earnings_tab.zig index 972a441..3828749 100644 --- a/src/tui/earnings_tab.zig +++ b/src/tui/earnings_tab.zig @@ -179,8 +179,7 @@ fn loadData(state: *State, app: *App) void { // ── Rendering ───────────────────────────────────────────────── -pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine { - const state = &app.states.earnings; +pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine { // wall-clock required: per-frame "now" for the earnings // "data Xs ago" readout. Captured here so the pure renderer below // stays free of io. diff --git a/src/tui/history_tab.zig b/src/tui/history_tab.zig index 4b57306..34615a6 100644 --- a/src/tui/history_tab.zig +++ b/src/tui/history_tab.zig @@ -202,6 +202,10 @@ pub const tab = struct { pub fn activate(state: *State, app: *App) !void { if (state.loaded) return; + // History reads `app.portfolio.summary` and `.file`. + // Ensure they're populated even when the user jumps + // straight here without visiting portfolio first. + app.ensurePortfolioDataLoaded(); loadData(state, app); } diff --git a/src/tui/options_tab.zig b/src/tui/options_tab.zig index e7bbb96..60f5961 100644 --- a/src/tui/options_tab.zig +++ b/src/tui/options_tab.zig @@ -9,7 +9,18 @@ const framework = @import("tab_framework.zig"); const App = tui.App; const StyledLine = tui.StyledLine; -const OptionsRow = tui.OptionsRow; + +/// One row in the options tab's flattened display list. Mix of +/// expiration-group headers, calls/puts section headers, and +/// individual contract rows; rebuilt by `rebuildRows` whenever +/// expansion state changes. +pub const OptionsRowKind = enum { expiration, calls_header, puts_header, call, put }; + +pub const OptionsRow = struct { + kind: OptionsRowKind, + exp_idx: usize = 0, // index into options_data chains + contract: ?zfin.OptionContract = null, +}; // ── Tab-local action enum ───────────────────────────────────── // @@ -452,8 +463,7 @@ pub fn formatUnderlyingHeader( // ── Rendering ───────────────────────────────────────────────── -pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine { - const state = &app.states.options; +pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine { const th = app.theme; var lines: std.ArrayList(StyledLine) = .empty; @@ -630,8 +640,8 @@ test "rebuildRows: collapsed expirations emit only the expiration rows" { defer state.rows.deinit(testing.allocator); // Two collapsed expirations = two rows. try testing.expectEqual(@as(usize, 2), state.rows.items.len); - try testing.expectEqual(tui.OptionsRowKind.expiration, state.rows.items[0].kind); - try testing.expectEqual(tui.OptionsRowKind.expiration, state.rows.items[1].kind); + try testing.expectEqual(OptionsRowKind.expiration, state.rows.items[0].kind); + try testing.expectEqual(OptionsRowKind.expiration, state.rows.items[1].kind); } test "rebuildRows: expanded expiration emits headers + filtered contracts" { diff --git a/src/tui/performance_tab.zig b/src/tui/performance_tab.zig index 01e1a9a..dc013ec 100644 --- a/src/tui/performance_tab.zig +++ b/src/tui/performance_tab.zig @@ -182,7 +182,8 @@ pub fn formatPerformanceHeader( std.fmt.allocPrint(arena, " Trailing Returns: {s}", .{symbol}); } -pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine { +pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine { + _ = state; const th = app.theme; var lines: std.ArrayList(StyledLine) = .empty; diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index 8a17d14..c500d39 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -13,9 +13,6 @@ 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; @@ -25,6 +22,94 @@ const glyph = tui.glyph; const prefix_cols: usize = 4; const sw: usize = fmt.sym_col_width; +// ── Portfolio-specific types ────────────────────────────────── + +/// Sortable columns in the portfolio table. Bound to the +/// `sort_col_next` / `sort_col_prev` actions; the `sort_reverse` +/// action flips the current `SortDirection`. +pub const PortfolioSortField = enum { + symbol, + shares, + avg_cost, + price, + market_value, + gain_loss, + weight, + account, + + pub fn label(self: PortfolioSortField) []const u8 { + return switch (self) { + .symbol => "Symbol", + .shares => "Shares", + .avg_cost => "Avg Cost", + .price => "Price", + .market_value => "Market Value", + .gain_loss => "Gain/Loss", + .weight => "Weight", + .account => "Account", + }; + } + + pub fn next(self: PortfolioSortField) ?PortfolioSortField { + const fields = std.meta.fields(PortfolioSortField); + const idx: usize = @intFromEnum(self); + if (idx + 1 >= fields.len) return null; + return @enumFromInt(idx + 1); + } + + pub fn prev(self: PortfolioSortField) ?PortfolioSortField { + const idx: usize = @intFromEnum(self); + if (idx == 0) return null; + return @enumFromInt(idx - 1); + } +}; + +/// Sort direction for the portfolio table. +pub const SortDirection = enum { + asc, + desc, + + pub fn flip(self: SortDirection) SortDirection { + return if (self == .asc) .desc else .asc; + } + + pub fn indicator(self: SortDirection) []const u8 { + return if (self == .asc) "▲" else "▼"; + } +}; + +/// One row in the portfolio table's flattened display list. +/// Covers position rows, lot rows (when expanded), watchlist +/// entries, section headers, options/CDs/cash/illiquid summary +/// rows, and DRIP-summary rows. Rebuilt by +/// `rebuildPortfolioRows` whenever sort / filter / expansion +/// changes. +pub const PortfolioRow = struct { + kind: Kind, + symbol: []const u8, + /// For position rows: index into allocations; for lot rows: lot data. + pos_idx: usize = 0, + lot: ?zfin.Lot = null, + /// Number of lots for this symbol (set on position rows) + lot_count: usize = 0, + /// DRIP summary data (for drip_summary rows) + drip_is_lt: bool = false, // true = LT summary, false = ST summary + drip_lot_count: usize = 0, + drip_shares: f64 = 0, + drip_avg_cost: f64 = 0, + drip_date_first: ?zfin.Date = null, + drip_date_last: ?zfin.Date = null, + /// Pre-formatted text from view model (options and CDs) + prepared_text: ?[]const u8 = null, + /// Semantic styles from view model + row_style: fmt.StyleIntent = .normal, + premium_style: fmt.StyleIntent = .normal, + /// Column offset for premium alt-style coloring (options only) + premium_col_start: usize = 0, + + const Kind = enum { position, lot, watchlist, section_header, option_row, cd_row, cash_row, cash_total, illiquid_row, illiquid_total, drip_summary }; +}; + // ── Tab-local action enum ───────────────────────────────────── // // Portfolio tab keybinds (today routed through legacy global @@ -130,12 +215,6 @@ pub const State = struct { /// 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, - // ── Account picker / search modal ── // // The portfolio tab owns picker state in full: the cursor, @@ -227,7 +306,13 @@ pub const tab = struct { } pub fn activate(state: *State, app: *App) !void { - if (app.portfolio.loaded) return; + // `loadPortfolioData` calls `ensurePortfolioDataLoaded` + // (idempotent — short-circuits when data is already + // loaded) and then unconditionally rebuilds the + // portfolio-tab UI state (sort, account list, rows). + // Skipping the UI rebuild on cache hit would leave a + // freshly-activated tab with no rows when the data was + // pre-loaded by App startup or another tab. loadPortfolioData(state, app); } @@ -288,7 +373,7 @@ pub const tab = struct { .clear_account_filter => { // No-op when no filter is active. if (state.account_filter == null) return; - app.setAccountFilter(null); + setAccountFilter(state, app, null); state.cursor = 0; app.scroll_offset = 0; rebuildPortfolioRows(state, app); @@ -493,7 +578,7 @@ pub const col_end_date: usize = col_end_weight + 14; const gl_col_start: usize = col_end_market_value; /// Map a semantic StyleIntent to a platform-specific vaxis style. -fn mapIntent(th: anytype, intent: fmt.StyleIntent) @import("vaxis").Style { +fn mapIntent(th: theme.Theme, intent: fmt.StyleIntent) vaxis.Style { return th.styleFor(intent); } @@ -509,212 +594,56 @@ 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. +/// Set up the portfolio tab's UI state from current +/// `app.portfolio` data: sort allocations per current sort +/// field, build the account list, recompute filtered positions +/// (when an account filter is active), and rebuild the styled +/// row list. Called from `activate` AFTER +/// `app.ensurePortfolioDataLoaded()`. No-op when no summary is +/// available. +/// +/// Call paths: +/// 1. First tab visit: `tab.activate` → here +/// 2. Manual refresh (r/F5): `tab.reload` clears +/// `app.portfolio.loaded` → `tab.activate` → ensurePortfolioDataLoaded → here +/// 3. Disk reload (R): `reloadPortfolioFile` — separate +/// function, cache-only, no network +/// +/// Tab switching skips this entirely because `tab.activate`'s +/// own guard short-circuits when `state.loaded` (TODO: this +/// flag doesn't exist yet on portfolio.State; today the guard +/// is `app.portfolio.loaded` which `ensurePortfolioDataLoaded` +/// owns. Visiting portfolio after analysis pre-loaded the data +/// will still rebuild the row list — cheap.) pub fn loadPortfolioData(state: *State, app: *App) void { - app.portfolio.loaded = true; - app.freePortfolioSummary(); + app.ensurePortfolioDataLoaded(); - const pf = app.portfolio.file orelse return; - - const positions = pf.positions(app.today, app.allocator) catch { - app.setStatus("Error computing positions"); - return; - }; - defer app.allocator.free(positions); - - var prices = std.StringHashMap(f64).init(app.allocator); - defer prices.deinit(); - - // Only fetch prices for stock/ETF symbols (skip options, CDs, cash) - const syms = pf.stockSymbols(app.allocator) catch { - app.setStatus("Error getting symbols"); - return; - }; - defer app.allocator.free(syms); - - var latest_date: ?zfin.Date = null; - var fail_count: usize = 0; - var fetch_count: usize = 0; - var stale_count: usize = 0; - var failed_syms: [8][]const u8 = undefined; - - 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| { - if (pp.get(sym)) |price| { - prices.put(sym, price) catch {}; - } - } - - // Extract watchlist prices - if (app.portfolio.watchlist_prices) |*wp| wp.clearRetainingCapacity() else { - app.portfolio.watchlist_prices = std.StringHashMap(f64).init(app.allocator); - } - var wp = &(app.portfolio.watchlist_prices.?); - var pp_iter = pp.iterator(); - while (pp_iter.next()) |entry| { - if (!prices.contains(entry.key_ptr.*)) { - wp.put(entry.key_ptr.*, entry.value_ptr.*) catch {}; - } - } - - pp.deinit(); - state.prefetched_prices = null; - } else { - // Live fetch (refresh path) — fetch watchlist first, then stock prices - if (app.portfolio.watchlist_prices) |*wp| wp.clearRetainingCapacity() else { - app.portfolio.watchlist_prices = std.StringHashMap(f64).init(app.allocator); - } - var wp = &(app.portfolio.watchlist_prices.?); - if (app.watchlist) |wl| { - for (wl) |sym| { - const result = app.svc.getCandles(sym) catch continue; - defer result.deinit(); - if (result.data.len > 0) { - wp.put(sym, result.data[result.data.len - 1].close) catch {}; - } - } - } - for (pf.lots) |lot| { - if (lot.security_type == .watch) { - const sym = lot.priceSymbol(); - const result = app.svc.getCandles(sym) catch continue; - defer result.deinit(); - if (result.data.len > 0) { - wp.put(sym, result.data[result.data.len - 1].close) catch {}; - } - } - } - - // Fetch stock prices with TUI status-bar progress - const TuiProgress = struct { - app: *App, - failed: *[8][]const u8, - fail_n: usize = 0, - - fn onProgress(ctx: *anyopaque, _: usize, _: usize, symbol: []const u8, status: zfin.DataService.SymbolStatus) void { - const s: *@This() = @ptrCast(@alignCast(ctx)); - switch (status) { - .fetching => { - var buf: [64]u8 = undefined; - const msg = std.fmt.bufPrint(&buf, "Loading {s}...", .{symbol}) catch "Loading..."; - s.app.setStatus(msg); - }, - .failed, .failed_used_stale => { - if (s.fail_n < s.failed.len) { - s.failed[s.fail_n] = symbol; - s.fail_n += 1; - } - }, - else => {}, - } - } - - fn callback(s: *@This()) zfin.DataService.ProgressCallback { - return .{ - .context = @ptrCast(s), - .on_progress = onProgress, - }; - } - }; - 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; - } - app.portfolio.latest_quote_date = latest_date; - - // Build portfolio summary, candle map, and historical snapshots - var pf_data = cli.buildPortfolioData(app.allocator, pf, positions, syms, &prices, app.svc, app.today) catch |err| switch (err) { - error.NoAllocations => { - app.setStatus("No cached prices. Run: zfin perf first"); - return; - }, - error.SummaryFailed => { - app.setStatus("Error computing portfolio summary"); - return; - }, - else => { - app.setStatus("Error building portfolio data"); - return; - }, - }; - // Transfer ownership: summary stored on App, candle_map freed after snapshots extracted - app.portfolio.summary = pf_data.summary; - app.portfolio.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| app.allocator.free(v.*); - pf_data.candle_map.deinit(); - } + // App may have failed to load — check before touching summary. + const summary = app.portfolio.summary orelse return; sortPortfolioAllocations(state, app); buildAccountList(state, app); recomputeFilteredPositions(state, app); rebuildPortfolioRows(state, app); - const summary = pf_data.summary; + // Pre-select the first row when no symbol is active yet. + // Runs AFTER `sortPortfolioAllocations` so the default + // matches what the user sees at the top of the table — + // alphabetically first by symbol with the default sort, + // not whatever lot happens to appear first in + // `portfolio.srf`. This is the "user just started the TUI; + // pick something sensible" path; once `app.symbol` is set + // (by user action or `--symbol`), this is a no-op. if (app.symbol.len == 0 and summary.allocations.len > 0) { app.setActiveSymbol(summary.allocations[0].symbol); } - - // Show warning if any securities failed to load - if (fail_count > 0) { - var warn_buf: [256]u8 = undefined; - if (fail_count <= 3) { - // Show actual symbol names for easier debugging - var sym_buf: [128]u8 = undefined; - var sym_len: usize = 0; - const show = @min(fail_count, failed_syms.len); - for (0..show) |fi| { - if (sym_len > 0) { - if (sym_len + 2 < sym_buf.len) { - sym_buf[sym_len] = ','; - sym_buf[sym_len + 1] = ' '; - sym_len += 2; - } - } - const s = failed_syms[fi]; - const copy_len = @min(s.len, sym_buf.len - sym_len); - @memcpy(sym_buf[sym_len..][0..copy_len], s[0..copy_len]); - sym_len += copy_len; - } - 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"; - 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"; - 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"; - 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"; - 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"; - app.setStatus(info_msg); - } else { - // Empty status — App's getStatus() will fall back to the - // dynamic default hint composed from the active tab's - // status_hints + global keys. - app.setStatus(""); - } } pub fn sortPortfolioAllocations(state: *State, app: *App) void { if (app.portfolio.summary) |s| { const SortCtx = struct { field: PortfolioSortField, - dir: tui.SortDirection, + dir: SortDirection, fn lessThan(ctx: @This(), a: zfin.valuation.Allocation, b: zfin.valuation.Allocation) bool { const lhs = if (ctx.dir == .asc) a else b; @@ -1012,6 +941,26 @@ pub fn rebuildPortfolioRows(state: *State, app: *App) void { } } +/// Set or clear the account filter on portfolio.State. Owns +/// the filter string via allocator (dup on set, free on +/// clear/replace) and recomputes `filtered_positions` from +/// `app.portfolio.file` so subsequent renders don't have to +/// re-iterate lots. Pass `null` to clear. +pub fn setAccountFilter(state: *State, app: *App, name: ?[]const u8) void { + if (state.account_filter) |old| app.allocator.free(old); + if (state.filtered_positions) |fp| app.allocator.free(fp); + state.filtered_positions = null; + + if (name) |n| { + state.account_filter = app.allocator.dupe(u8, n) catch null; + if (app.portfolio.file) |pf| { + state.filtered_positions = pf.positionsForAccount(app.today, app.allocator, n) catch null; + } + } else { + state.account_filter = null; + } +} + /// Build the ordered list of distinct account names from portfolio lots. /// Order: accounts.srf file order first, then any remaining accounts alphabetically. /// Also assigns shortcut keys and loads account numbers from accounts.srf. @@ -1088,7 +1037,7 @@ pub fn buildAccountList(state: *State, app: *App) void { break; } } - if (!found) app.setAccountFilter(null); + if (!found) setAccountFilter(state, app, null); } } @@ -2205,11 +2154,11 @@ fn containsLower(haystack: []const u8, needle_lower: []const u8) bool { fn applyAccountPickerSelection(state: *State, app: *App) void { if (state.account_picker_cursor == 0) { // "All accounts" — clear filter - app.setAccountFilter(null); + setAccountFilter(state, app, null); } else { const idx = state.account_picker_cursor - 1; if (idx < state.account_list.items.len) { - app.setAccountFilter(state.account_list.items[idx]); + setAccountFilter(state, app, state.account_list.items[idx]); } } state.modal = .none; @@ -2230,6 +2179,29 @@ fn applyAccountPickerSelection(state: *State, app: *App) void { const testing = std.testing; +test "PortfolioSortField next/prev" { + // next from first field + try testing.expectEqual(PortfolioSortField.shares, PortfolioSortField.symbol.next().?); + // next from last field returns null + try testing.expectEqual(@as(?PortfolioSortField, null), PortfolioSortField.account.next()); + // prev from first returns null + try testing.expectEqual(@as(?PortfolioSortField, null), PortfolioSortField.symbol.prev()); + // prev from last + try testing.expectEqual(PortfolioSortField.weight, PortfolioSortField.account.prev().?); +} + +test "PortfolioSortField label" { + try testing.expectEqualStrings("Symbol", PortfolioSortField.symbol.label()); + try testing.expectEqualStrings("Market Value", PortfolioSortField.market_value.label()); +} + +test "SortDirection flip and indicator" { + try testing.expectEqual(SortDirection.desc, SortDirection.asc.flip()); + try testing.expectEqual(SortDirection.asc, SortDirection.desc.flip()); + try testing.expectEqualStrings("\xe2\x96\xb2", SortDirection.asc.indicator()); // ▲ + try testing.expectEqualStrings("\xe2\x96\xbc", SortDirection.desc.indicator()); // ▼ +} + test "buildWelcomeScreenLines: includes resolved keys in expected slots" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); diff --git a/src/tui/projections_tab.zig b/src/tui/projections_tab.zig index e96e453..101d1e7 100644 --- a/src/tui/projections_tab.zig +++ b/src/tui/projections_tab.zig @@ -192,6 +192,10 @@ pub const tab = struct { pub fn activate(state: *State, app: *App) !void { if (state.loaded) return; + // Projections reads `app.portfolio.summary` and + // `.file`. Ensure they're populated even when the user + // jumps straight here without visiting portfolio first. + app.ensurePortfolioDataLoaded(); loadData(state, app); } @@ -533,8 +537,7 @@ pub fn freeLoaded(state: *State, app: *App) void { // ── Rendering ───────────────────────────────────────────────── -pub fn drawContent(state: *State, app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void { - const arena = ctx.arena; +pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { // Determine whether to use Kitty graphics const use_kitty = switch (app.chart_config.mode) { @@ -555,7 +558,7 @@ pub fn drawContent(state: *State, app: *App, ctx: vaxis.vxfw.DrawContext, buf: [ } else false; if (use_kitty and has_bands and state.chart_visible) { - drawWithKittyChart(state, app, ctx, buf, width, height) catch { + drawWithKittyChart(state, app, arena, buf, width, height) catch { try drawWithScroll(state, app, arena, buf, width, height); }; } else { @@ -565,14 +568,13 @@ pub fn drawContent(state: *State, app: *App, ctx: vaxis.vxfw.DrawContext, buf: [ /// Render styled lines with scroll_offset applied. fn drawWithScroll(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { - const all_lines = try buildStyledLines(state, app, arena); + const all_lines = try buildLines(state, app, arena); const start = @min(app.scroll_offset, if (all_lines.len > 0) all_lines.len - 1 else 0); try app.drawStyledContent(arena, buf, width, height, all_lines[start..]); } /// Draw projections tab using Kitty graphics protocol for the percentile band chart. -fn drawWithKittyChart(state: *State, app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void { - const arena = ctx.arena; +fn drawWithKittyChart(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { const th = app.theme; const pctx = state.ctx orelse return; const config = pctx.config; @@ -613,8 +615,9 @@ fn drawWithKittyChart(state: *State, app: *App, ctx: vaxis.vxfw.DrawContext, buf } // Compute pixel dimensions - const cell_w: u32 = if (ctx.cell_size.width > 0) ctx.cell_size.width else 8; - const cell_h: u32 = if (ctx.cell_size.height > 0) ctx.cell_size.height else 16; + const cell_size = app.cellPixelSize(); + const cell_w: u32 = cell_size.width; + const cell_h: u32 = cell_size.height; const label_cols: u16 = 12; // columns for axis labels on the right const chart_cols = width -| 2 -| label_cols; if (chart_cols == 0) return; @@ -1154,7 +1157,12 @@ fn appendAccumulationBlocks( } } -pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine { +/// Build the styled-line representation of the projections +/// view (text-only fallback when the chart is hidden, and the +/// scroll body when the chart is visible). File-private — the +/// framework draw hook is `drawContent`, which composes this +/// internally. +fn buildLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine { const th = app.theme; var lines: std.ArrayListUnmanaged(StyledLine) = .empty; diff --git a/src/tui/quote_tab.zig b/src/tui/quote_tab.zig index 774c035..751e4cd 100644 --- a/src/tui/quote_tab.zig +++ b/src/tui/quote_tab.zig @@ -12,6 +12,62 @@ const App = tui.App; const StyledLine = tui.StyledLine; const glyph = tui.glyph; +/// Per-symbol chart state for the quote tab. Tracks the active +/// timeframe, transmitted Kitty image (when supported), cached +/// indicator overlays (SMA/Bollinger/etc), and last-rendered +/// data fingerprints used to decide when to re-render. +pub const ChartState = struct { + timeframe: chart.Timeframe = .@"1Y", + image_id: ?u32 = null, // currently transmitted Kitty image ID + image_width: u16 = 0, // image width in cells + image_height: u16 = 0, // image height in cells + symbol: [16]u8 = undefined, // symbol the chart was rendered for + symbol_len: usize = 0, + timeframe_rendered: ?chart.Timeframe = null, // timeframe the chart was rendered for + timeframe_row: ?usize = null, // screen row of the timeframe selector (for mouse clicks) + dirty: bool = true, // needs re-render + price_min: f64 = 0, + price_max: f64 = 0, + rsi_latest: ?f64 = null, + + // Cached indicator data (persists across frames to avoid recomputation) + cached_indicators: ?chart.CachedIndicators = null, + cache_candle_count: usize = 0, // candle count when cache was computed + cache_timeframe: ?chart.Timeframe = null, // timeframe when cache was computed + cache_last_close: f64 = 0, // last candle's close when cache was computed + + /// Free cached indicator memory. + pub fn freeCache(self: *ChartState, alloc: std.mem.Allocator) void { + if (self.cached_indicators) |*cache| { + cache.deinit(alloc); + self.cached_indicators = null; + } + self.cache_candle_count = 0; + self.cache_timeframe = null; + self.cache_last_close = 0; + } + + /// Check if cache is valid for the given candle data and timeframe. + pub fn isCacheValid(self: *const ChartState, candles: []const zfin.Candle, timeframe: chart.Timeframe) bool { + if (self.cached_indicators == null) return false; + if (self.cache_timeframe == null or self.cache_timeframe.? != timeframe) return false; + + // Slice candles to timeframe (same logic as renderChart) + const max_days = timeframe.tradingDays(); + const n = @min(candles.len, max_days); + const data = candles[candles.len - n ..]; + + if (data.len != self.cache_candle_count) return false; + if (data.len == 0) return false; + + // Check if last close changed (detects data refresh) + const last_close = data[data.len - 1].close; + if (@abs(last_close - self.cache_last_close) > 0.0001) return false; + + return true; + } +}; + // ── Tab-local action enum ───────────────────────────────────── // // Quote tab cycles the chart timeframe with `[` and `]` (chart- @@ -37,7 +93,7 @@ pub const State = struct { /// indicator cache + timeframe selection). Lives here because /// only the quote tab uses it; perf renders its own braille /// chart from `app.symbol_data.candles` directly. - chart: tui.ChartState = .{}, + chart: ChartState = .{}, }; // ── Tab framework contract ──────────────────────────────────── @@ -181,8 +237,8 @@ pub const tab = struct { /// Draw the quote tab content. Uses Kitty graphics for the chart when available, /// falling back to braille sparkline otherwise. -pub fn drawContent(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void { - const arena = ctx.arena; +pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { + _ = state; // Determine whether to use Kitty graphics const use_kitty = switch (app.chart_config.mode) { @@ -192,7 +248,7 @@ pub fn drawContent(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, wi }; if (use_kitty and app.symbol_data.candles != null and app.symbol_data.candles.?.len >= 40) { - drawWithKittyChart(app, ctx, buf, width, height) catch { + drawWithKittyChart(app, arena, buf, width, height) catch { // On any failure, fall back to braille try app.drawStyledContent(arena, buf, width, height, try buildStyledLines(app, arena)); }; @@ -203,8 +259,7 @@ pub fn drawContent(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, wi } /// Draw quote tab using Kitty graphics protocol for the chart. -fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void { - const arena = ctx.arena; +fn drawWithKittyChart(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { const th = app.theme; const c = app.symbol_data.candles orelse return; @@ -286,8 +341,9 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, // Compute pixel dimensions from cell size // cell_size may be 0 if terminal hasn't reported pixel dimensions yet - const cell_w: u32 = if (ctx.cell_size.width > 0) ctx.cell_size.width else 8; - const cell_h: u32 = if (ctx.cell_size.height > 0) ctx.cell_size.height else 16; + const cell_size = app.cellPixelSize(); + const cell_w: u32 = cell_size.width; + const cell_h: u32 = cell_size.height; const label_cols: u16 = 10; // columns reserved for axis labels on the right const chart_cols = width -| 2 -| label_cols; // 1 col left margin + label area on right if (chart_cols == 0) return; diff --git a/src/tui/tab_framework.zig b/src/tui/tab_framework.zig index 14f1830..1750c38 100644 --- a/src/tui/tab_framework.zig +++ b/src/tui/tab_framework.zig @@ -113,6 +113,7 @@ const std = @import("std"); const vaxis = @import("vaxis"); +const StyledLine = @import("../tui.zig").StyledLine; /// Re-exported KeyCombo so tab modules don't need to import /// keybinds.zig directly for binding declarations. This is the @@ -402,6 +403,53 @@ pub fn validateTabModule(comptime Module: type) void { ); } + // ── Draw hooks (mutually exclusive, exactly one required) ── + // + // Every tab declares EXACTLY ONE of: + // + // pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine + // pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void + // + // `buildStyledLines` is for line-list tabs (App handles + // scroll_offset clamping + cell rendering). `drawContent` + // is for direct-buffer tabs (cell layout / Kitty + // graphics that don't fit the line-list shape). + // + // The framework's draw dispatcher tries `drawContent` + // first, then falls back to `buildStyledLines`. Declaring + // neither leaves the tab unrenderable; declaring both is + // ambiguous. The validator surfaces both as compile errors. + const has_build = @hasDecl(Module, "buildStyledLines"); + const has_draw = @hasDecl(Module, "drawContent"); + if (!has_build and !has_draw) { + @compileError("Tab module `" ++ mod_name ++ "` must declare exactly one of " ++ + "`buildStyledLines` or `drawContent`. See the framework draw-hook docs in tab_framework.zig."); + } + if (has_build and has_draw) { + @compileError("Tab module `" ++ mod_name ++ "` declares both `buildStyledLines` and " ++ + "`drawContent`. Only one is allowed — pick the right one for your tab's render shape."); + } + if (has_build) { + expectFnInferredError( + mod_name, + Module, + "buildStyledLines", + &.{ *State, *App, std.mem.Allocator }, + []const StyledLine, + "pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine { ... }", + ); + } + if (has_draw) { + expectFnInferredError( + mod_name, + Module, + "drawContent", + &.{ *State, *App, std.mem.Allocator, []vaxis.Cell, u16, u16 }, + void, + "pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { ... }", + ); + } + // ── Context-change hooks (optional, typed when present) ── if (@hasDecl(tab_decl, "onSymbolChange")) { expectFn(