const std = @import("std"); const vaxis = @import("vaxis"); const zfin = @import("root.zig"); const fmt = @import("format.zig"); const cli = @import("commands/common.zig"); const keybinds = @import("tui/keybinds.zig"); const theme_mod = @import("tui/theme.zig"); const chart_mod = @import("tui/chart.zig"); const portfolio_tab = @import("tui/portfolio_tab.zig"); const quote_tab = @import("tui/quote_tab.zig"); const perf_tab = @import("tui/perf_tab.zig"); const options_tab = @import("tui/options_tab.zig"); const earnings_tab = @import("tui/earnings_tab.zig"); const analysis_tab = @import("tui/analysis_tab.zig"); /// Comptime-generated table of single-character grapheme slices with static lifetime. /// This avoids dangling pointers from stack-allocated temporaries in draw functions. const ascii_g = blk: { var table: [128][]const u8 = undefined; for (0..128) |i| { const ch: [1]u8 = .{@as(u8, @intCast(i))}; table[i] = &ch; } break :blk table; }; /// Build a fixed-display-width column header label with optional sort indicator. /// The indicator (▲/▼, 3 bytes, 1 display column) replaces a padding space so total /// display width stays constant. Indicator always appears on the left side. /// `left` controls text alignment (left-aligned vs right-aligned). pub fn colLabel(buf: []u8, name: []const u8, comptime col_width: usize, left: bool, indicator: ?[]const u8) []const u8 { const ind = indicator orelse { // No indicator: plain padded label if (left) { @memset(buf[0..col_width], ' '); @memcpy(buf[0..name.len], name); return buf[0..col_width]; } else { @memset(buf[0..col_width], ' '); const offset = col_width - name.len; @memcpy(buf[offset..][0..name.len], name); return buf[0..col_width]; } }; // Indicator always on the left, replacing one padding space. // total display cols = col_width, byte length = col_width - 1 + ind.len const total_bytes = col_width - 1 + ind.len; if (total_bytes > buf.len) return name; if (left) { // "▲Name " — indicator, text, then spaces @memcpy(buf[0..ind.len], ind); @memcpy(buf[ind.len..][0..name.len], name); const content_len = ind.len + name.len; if (content_len < total_bytes) @memset(buf[content_len..total_bytes], ' '); } else { // " ▲Name" — spaces, indicator, then text const pad = col_width - name.len - 1; @memset(buf[0..pad], ' '); @memcpy(buf[pad..][0..ind.len], ind); @memcpy(buf[pad + ind.len ..][0..name.len], name); } return buf[0..total_bytes]; } pub fn glyph(ch: u8) []const u8 { if (ch < 128) return ascii_g[ch]; return " "; } pub const Tab = enum { portfolio, quote, performance, options, earnings, analysis, fn label(self: Tab) []const u8 { return switch (self) { .portfolio => " 1:Portfolio ", .quote => " 2:Quote ", .performance => " 3:Performance ", .options => " 4:Options ", .earnings => " 5:Earnings ", .analysis => " 6:Analysis ", }; } }; const tabs = [_]Tab{ .portfolio, .quote, .performance, .options, .earnings, .analysis }; pub const InputMode = enum { normal, symbol_input, help, }; pub const StyledLine = struct { text: []const u8, style: vaxis.Style, // Optional per-character style override ranges (for mixed-color lines) alt_text: ?[]const u8 = null, // text for the gain/loss column alt_style: ?vaxis.Style = null, alt_start: usize = 0, alt_end: usize = 0, // Optional pre-encoded grapheme array for multi-byte Unicode (e.g. braille charts). // When set, each element is a grapheme string for one column position. graphemes: ?[]const []const u8 = null, // Optional per-cell style array (same length as graphemes). Enables color gradients. cell_styles: ?[]const vaxis.Style = null, }; // ── 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, 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 { config: chart_mod.ChartConfig = .{}, timeframe: chart_mod.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_mod.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, }; /// Root widget for the interactive TUI. Implements the vaxis `vxfw.Widget` /// interface via `widget()`, which wires `typeErasedEventHandler` and /// `typeErasedDrawFn` as callbacks. Passed to `vxfw.App.run()` as the /// top-level widget; vaxis drives the event loop, calling back into App /// for key/mouse/init events and for each frame's draw. /// /// Owns all application state: the active tab, cached data for each tab, /// navigation/scroll positions, input mode, and a reference to the /// `DataService` for fetching financial data. Tab-specific rendering and /// data loading are delegated to the `tui/*_tab.zig` modules. pub const App = struct { allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataService, keymap: keybinds.KeyMap, theme: theme_mod.Theme, active_tab: Tab = .portfolio, symbol: []const u8 = "", symbol_buf: [16]u8 = undefined, symbol_owned: bool = false, scroll_offset: usize = 0, visible_height: u16 = 24, // updated each draw has_explicit_symbol: bool = false, // true if -s was used portfolio: ?zfin.Portfolio = null, portfolio_path: ?[]const u8 = null, watchlist: ?[][]const u8 = null, watchlist_path: ?[]const u8 = null, status_msg: [256]u8 = undefined, status_len: usize = 0, // Input mode state mode: InputMode = .normal, input_buf: [16]u8 = undefined, input_len: usize = 0, // Portfolio navigation cursor: usize = 0, // selected row in portfolio view expanded: [64]bool = [_]bool{false} ** 64, // which positions are expanded cash_expanded: bool = false, // whether cash section is expanded to show per-account illiquid_expanded: bool = false, // whether illiquid section is expanded to show per-asset portfolio_rows: std.ArrayList(PortfolioRow) = .empty, portfolio_header_lines: usize = 0, // number of styled lines before data rows portfolio_line_to_row: [256]usize = [_]usize{0} ** 256, // maps styled line index -> portfolio_rows index portfolio_line_count: usize = 0, // total styled lines in portfolio view portfolio_sort_field: PortfolioSortField = .symbol, // current sort column portfolio_sort_dir: SortDirection = .asc, // current sort direction watchlist_prices: ?std.StringHashMap(f64) = null, // cached watchlist prices (no disk I/O during render) prefetched_prices: ?std.StringHashMap(f64) = null, // prices loaded before TUI starts (with stderr progress) // Options navigation (inline expand/collapse like portfolio) options_cursor: usize = 0, // selected row in flattened options view options_expanded: [64]bool = [_]bool{false} ** 64, // which expirations are expanded options_calls_collapsed: [64]bool = [_]bool{false} ** 64, // per-expiration: calls section collapsed options_puts_collapsed: [64]bool = [_]bool{false} ** 64, // per-expiration: puts section collapsed options_near_the_money: usize = 8, // +/- strikes from ATM options_rows: std.ArrayList(OptionsRow) = .empty, options_header_lines: usize = 0, // number of styled lines before data rows // Cached data for rendering candles: ?[]zfin.Candle = null, dividends: ?[]zfin.Dividend = null, earnings_data: ?[]zfin.EarningsEvent = null, options_data: ?[]zfin.OptionsChain = null, portfolio_summary: ?zfin.valuation.PortfolioSummary = null, historical_snapshots: ?[zfin.valuation.HistoricalPeriod.all.len]zfin.valuation.HistoricalSnapshot = null, risk_metrics: ?zfin.risk.TrailingRisk = null, trailing_price: ?zfin.performance.TrailingReturns = null, trailing_total: ?zfin.performance.TrailingReturns = null, trailing_me_price: ?zfin.performance.TrailingReturns = null, trailing_me_total: ?zfin.performance.TrailingReturns = null, candle_count: usize = 0, candle_first_date: ?zfin.Date = null, candle_last_date: ?zfin.Date = null, data_error: ?[]const u8 = null, perf_loaded: bool = false, earnings_loaded: bool = false, options_loaded: bool = false, portfolio_loaded: bool = false, // Data timestamps (unix seconds) candle_timestamp: i64 = 0, options_timestamp: i64 = 0, earnings_timestamp: i64 = 0, // Stored real-time quote (only fetched on manual refresh) quote: ?zfin.Quote = null, quote_timestamp: i64 = 0, // Track whether earnings tab should be disabled (ETF, no data) earnings_disabled: bool = false, // ETF profile (loaded lazily on quote tab) etf_profile: ?zfin.EtfProfile = null, etf_loaded: bool = false, // Signal to the run loop to launch $EDITOR then restart wants_edit: bool = false, // Analysis tab state analysis_result: ?zfin.analysis.AnalysisResult = null, analysis_loaded: bool = false, classification_map: ?zfin.classification.ClassificationMap = null, account_map: ?zfin.analysis.AccountMap = null, // Mouse wheel debounce for cursor-based tabs (portfolio, options). // Terminals often send multiple wheel events per physical tick. last_wheel_ns: i128 = 0, // Chart state (Kitty graphics) chart: ChartState = .{}, vx_app: ?*vaxis.vxfw.App = null, // set during run(), for Kitty graphics access pub fn widget(self: *App) vaxis.vxfw.Widget { return .{ .userdata = self, .eventHandler = typeErasedEventHandler, .drawFn = typeErasedDrawFn, }; } fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vaxis.vxfw.EventContext, event: vaxis.vxfw.Event) anyerror!void { const self: *App = @ptrCast(@alignCast(ptr)); switch (event) { .key_press => |key| { if (self.mode == .symbol_input) { return self.handleInputKey(ctx, key); } if (self.mode == .help) { self.mode = .normal; return ctx.consumeAndRedraw(); } return self.handleNormalKey(ctx, key); }, .mouse => |mouse| { return self.handleMouse(ctx, mouse); }, .init => { self.loadTabData(); }, else => {}, } } fn handleMouse(self: *App, ctx: *vaxis.vxfw.EventContext, mouse: vaxis.Mouse) void { switch (mouse.button) { .wheel_up => { self.moveBy(-3, true); return ctx.consumeAndRedraw(); }, .wheel_down => { self.moveBy(3, true); return ctx.consumeAndRedraw(); }, .left => { if (mouse.type == .press) { if (mouse.row == 0) { var col: i16 = 0; for (tabs) |t| { const lbl_len: i16 = @intCast(t.label().len); if (mouse.col >= col and mouse.col < col + lbl_len) { if (t == .earnings and self.earnings_disabled) return; self.active_tab = t; self.scroll_offset = 0; self.loadTabData(); return ctx.consumeAndRedraw(); } col += lbl_len; } } if (self.active_tab == .portfolio and mouse.row > 0) { const content_row = @as(usize, @intCast(mouse.row)) + self.scroll_offset; // Click on column header row -> sort by that column if (self.portfolio_header_lines > 0 and content_row == self.portfolio_header_lines - 1) { // Column boundaries derived from sym_col_width (sw). // prefix(4) + Symbol(sw+1) + Shares(8+1) + AvgCost(10+1) + Price(10+1) + MV(16+1) + G/L(14+1) + Weight(8) const sw = fmt.sym_col_width; const col = @as(usize, @intCast(mouse.col)); const new_field: ?PortfolioSortField = if (col < 4 + sw + 1) .symbol else if (col < 4 + sw + 10) .shares else if (col < 4 + sw + 21) .avg_cost else if (col < 4 + sw + 32) .price else if (col < 4 + sw + 49) .market_value else if (col < 4 + sw + 64) .gain_loss else if (col < 4 + sw + 73) .weight else if (col < 4 + sw + 87) null // Date (not sortable) else .account; if (new_field) |nf| { if (nf == self.portfolio_sort_field) { self.portfolio_sort_dir = self.portfolio_sort_dir.flip(); } else { self.portfolio_sort_field = nf; self.portfolio_sort_dir = if (nf == .symbol or nf == .account) .asc else .desc; } self.sortPortfolioAllocations(); self.rebuildPortfolioRows(); return ctx.consumeAndRedraw(); } } if (content_row >= self.portfolio_header_lines and self.portfolio_rows.items.len > 0) { const line_idx = content_row - self.portfolio_header_lines; if (line_idx < self.portfolio_line_count and line_idx < self.portfolio_line_to_row.len) { const row_idx = self.portfolio_line_to_row[line_idx]; if (row_idx < self.portfolio_rows.items.len) { self.cursor = row_idx; self.toggleExpand(); return ctx.consumeAndRedraw(); } } } } // Quote tab: click on timeframe selector to switch timeframes if (self.active_tab == .quote and mouse.row > 0) { if (self.chart.timeframe_row) |tf_row| { const content_row = @as(usize, @intCast(mouse.row)) + self.scroll_offset; if (content_row == tf_row) { // " Chart: [6M] YTD 1Y 3Y 5Y ([ ] to change)" // Prefix " Chart: " = 9 chars, then each TF takes label_len+2 (brackets/spaces) + 1 gap const col = @as(usize, @intCast(mouse.col)); const prefix_len: usize = 9; // " Chart: " if (col >= prefix_len) { const timeframes = [_]chart_mod.Timeframe{ .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" }; var x: usize = prefix_len; for (timeframes) |tf| { const lbl_len = tf.label().len; const slot_width = lbl_len + 2 + 1; // [XX] + space or XX + space if (col >= x and col < x + slot_width) { if (tf != self.chart.timeframe) { self.chart.timeframe = tf; self.setStatus(tf.label()); return ctx.consumeAndRedraw(); } break; } x += slot_width; } } } } } // Options tab: single-click to select and expand/collapse if (self.active_tab == .options and mouse.row > 0) { const content_row = @as(usize, @intCast(mouse.row)) + self.scroll_offset; if (content_row >= self.options_header_lines and self.options_rows.items.len > 0) { // Walk options_rows tracking styled line position to find which // row was clicked. Each row = 1 styled line, except puts_header // which emits an extra blank line before it. const target_line = content_row - self.options_header_lines; var current_line: usize = 0; for (self.options_rows.items, 0..) |orow, oi| { if (orow.kind == .puts_header) current_line += 1; // extra blank if (current_line == target_line) { self.options_cursor = oi; self.toggleOptionsExpand(); return ctx.consumeAndRedraw(); } current_line += 1; } } } } }, else => {}, } } fn handleInputKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void { if (key.codepoint == vaxis.Key.escape) { self.mode = .normal; self.input_len = 0; self.setStatus("Cancelled"); return ctx.consumeAndRedraw(); } if (key.codepoint == vaxis.Key.enter) { if (self.input_len > 0) { for (self.input_buf[0..self.input_len]) |*ch| ch.* = std.ascii.toUpper(ch.*); @memcpy(self.symbol_buf[0..self.input_len], self.input_buf[0..self.input_len]); self.symbol = self.symbol_buf[0..self.input_len]; self.symbol_owned = true; self.has_explicit_symbol = true; self.resetSymbolData(); self.active_tab = .quote; self.loadTabData(); } self.mode = .normal; self.input_len = 0; return ctx.consumeAndRedraw(); } if (key.codepoint == vaxis.Key.backspace) { if (self.input_len > 0) self.input_len -= 1; return ctx.consumeAndRedraw(); } if (key.matches('u', .{ .ctrl = true })) { self.input_len = 0; return ctx.consumeAndRedraw(); } if (key.codepoint >= 0x20 and key.codepoint < 0x7f and self.input_len < self.input_buf.len) { const c: u8 = @intCast(key.codepoint); self.input_buf[self.input_len] = std.ascii.toUpper(c); self.input_len += 1; return ctx.consumeAndRedraw(); } } fn handleNormalKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void { // Escape: no special behavior needed (options is now inline) if (key.codepoint == vaxis.Key.escape) { return; } // Ctrl+L: full screen redraw (standard TUI convention, not configurable) if (key.codepoint == 'l' and key.mods.ctrl) { ctx.queueRefresh() catch {}; return ctx.consumeAndRedraw(); } const action = self.keymap.matchAction(key) orelse return; switch (action) { .quit => { ctx.quit = true; }, .symbol_input => { self.mode = .symbol_input; self.input_len = 0; return ctx.consumeAndRedraw(); }, .select_symbol => { // 's' selects the current portfolio row's symbol as the active symbol if (self.active_tab == .portfolio and self.portfolio_rows.items.len > 0 and self.cursor < self.portfolio_rows.items.len) { const row = self.portfolio_rows.items[self.cursor]; self.setActiveSymbol(row.symbol); // Format into a separate buffer to avoid aliasing with status_msg var tmp_buf: [256]u8 = undefined; const msg = std.fmt.bufPrint(&tmp_buf, "Active: {s}", .{row.symbol}) catch "Active"; self.setStatus(msg); return ctx.consumeAndRedraw(); } }, .refresh => { self.refreshCurrentTab(); return ctx.consumeAndRedraw(); }, .prev_tab => { self.prevTab(); self.scroll_offset = 0; self.loadTabData(); return ctx.consumeAndRedraw(); }, .next_tab => { self.nextTab(); self.scroll_offset = 0; self.loadTabData(); return ctx.consumeAndRedraw(); }, .tab_1, .tab_2, .tab_3, .tab_4, .tab_5, .tab_6 => { const idx = @intFromEnum(action) - @intFromEnum(keybinds.Action.tab_1); if (idx < tabs.len) { const target = tabs[idx]; if (target == .earnings and self.earnings_disabled) return; self.active_tab = target; self.scroll_offset = 0; self.loadTabData(); return ctx.consumeAndRedraw(); } }, .select_next => { self.moveBy(1, false); return ctx.consumeAndRedraw(); }, .select_prev => { self.moveBy(-1, false); return ctx.consumeAndRedraw(); }, .expand_collapse => { if (self.active_tab == .portfolio) { self.toggleExpand(); return ctx.consumeAndRedraw(); } else if (self.active_tab == .options) { self.toggleOptionsExpand(); return ctx.consumeAndRedraw(); } }, .scroll_down => { const half = @max(1, self.visible_height / 2); self.scroll_offset += half; return ctx.consumeAndRedraw(); }, .scroll_up => { const half = @max(1, self.visible_height / 2); if (self.scroll_offset > half) self.scroll_offset -= half else self.scroll_offset = 0; return ctx.consumeAndRedraw(); }, .page_down => { self.scroll_offset += self.visible_height; return ctx.consumeAndRedraw(); }, .page_up => { if (self.scroll_offset > self.visible_height) self.scroll_offset -= self.visible_height else self.scroll_offset = 0; return ctx.consumeAndRedraw(); }, .scroll_top => { self.scroll_offset = 0; if (self.active_tab == .portfolio) self.cursor = 0; if (self.active_tab == .options) self.options_cursor = 0; return ctx.consumeAndRedraw(); }, .scroll_bottom => { self.scroll_offset = 999; if (self.active_tab == .portfolio and self.portfolio_rows.items.len > 0) self.cursor = self.portfolio_rows.items.len - 1; if (self.active_tab == .options and self.options_rows.items.len > 0) self.options_cursor = self.options_rows.items.len - 1; return ctx.consumeAndRedraw(); }, .help => { self.mode = .help; self.scroll_offset = 0; return ctx.consumeAndRedraw(); }, .edit => { if (self.portfolio_path != null or self.watchlist_path != null) { self.wants_edit = true; ctx.quit = true; } else { self.setStatus("No portfolio or watchlist file to edit"); return ctx.consumeAndRedraw(); } }, .reload_portfolio => { self.reloadPortfolioFile(); return ctx.consumeAndRedraw(); }, .collapse_all_calls => { if (self.active_tab == .options) { self.toggleAllCallsPuts(true); return ctx.consumeAndRedraw(); } }, .collapse_all_puts => { if (self.active_tab == .options) { self.toggleAllCallsPuts(false); return ctx.consumeAndRedraw(); } }, .options_filter_1, .options_filter_2, .options_filter_3, .options_filter_4, .options_filter_5, .options_filter_6, .options_filter_7, .options_filter_8, .options_filter_9 => { if (self.active_tab == .options) { const n = @intFromEnum(action) - @intFromEnum(keybinds.Action.options_filter_1) + 1; self.options_near_the_money = n; self.rebuildOptionsRows(); var tmp_buf: [256]u8 = undefined; const msg = std.fmt.bufPrint(&tmp_buf, "Filtered to +/- {d} strikes NTM", .{n}) catch "Filtered"; self.setStatus(msg); return ctx.consumeAndRedraw(); } }, .chart_timeframe_next => { if (self.active_tab == .quote) { self.chart.timeframe = self.chart.timeframe.next(); self.chart.dirty = true; self.setStatus(self.chart.timeframe.label()); return ctx.consumeAndRedraw(); } }, .chart_timeframe_prev => { if (self.active_tab == .quote) { self.chart.timeframe = self.chart.timeframe.prev(); self.chart.dirty = true; self.setStatus(self.chart.timeframe.label()); return ctx.consumeAndRedraw(); } }, .sort_col_next => { if (self.active_tab == .portfolio) { if (self.portfolio_sort_field.next()) |new_field| { self.portfolio_sort_field = new_field; self.portfolio_sort_dir = if (new_field == .symbol or new_field == .account) .asc else .desc; self.sortPortfolioAllocations(); self.rebuildPortfolioRows(); } return ctx.consumeAndRedraw(); } }, .sort_col_prev => { if (self.active_tab == .portfolio) { if (self.portfolio_sort_field.prev()) |new_field| { self.portfolio_sort_field = new_field; self.portfolio_sort_dir = if (new_field == .symbol or new_field == .account) .asc else .desc; self.sortPortfolioAllocations(); self.rebuildPortfolioRows(); } return ctx.consumeAndRedraw(); } }, .sort_reverse => { if (self.active_tab == .portfolio) { self.portfolio_sort_dir = self.portfolio_sort_dir.flip(); self.sortPortfolioAllocations(); self.rebuildPortfolioRows(); return ctx.consumeAndRedraw(); } }, } } /// Move cursor/scroll. Positive = down, negative = up. /// For portfolio and options tabs, moves the row cursor by 1. /// For other tabs, adjusts scroll_offset by |n|. /// When from_wheel is true, debounces on cursor tabs to absorb /// duplicate events that terminals send per physical scroll tick. fn moveBy(self: *App, n: isize, from_wheel: bool) void { if (self.active_tab == .portfolio or self.active_tab == .options) { if (from_wheel) { const now = std.time.nanoTimestamp(); if (now - self.last_wheel_ns < 1 * std.time.ns_per_ms) return; self.last_wheel_ns = now; } if (self.active_tab == .portfolio) { stepCursor(&self.cursor, self.portfolio_rows.items.len, n); self.ensureCursorVisible(); } else { stepCursor(&self.options_cursor, self.options_rows.items.len, n); self.ensureOptionsCursorVisible(); } } else { if (n > 0) { self.scroll_offset += @intCast(n); } else { const abs: usize = @intCast(-n); if (self.scroll_offset > abs) self.scroll_offset -= abs else self.scroll_offset = 0; } } } fn stepCursor(cursor: *usize, row_count: usize, direction: isize) void { if (direction > 0) { if (row_count > 0 and cursor.* < row_count - 1) cursor.* += 1; } else { if (cursor.* > 0) cursor.* -= 1; } } fn ensureCursorVisible(self: *App) void { const cursor_row = self.cursor + self.portfolio_header_lines; if (cursor_row < self.scroll_offset) { self.scroll_offset = cursor_row; } const vis: usize = self.visible_height; if (cursor_row >= self.scroll_offset + vis) { self.scroll_offset = cursor_row - vis + 1; } } fn toggleExpand(self: *App) void { if (self.portfolio_rows.items.len == 0) return; if (self.cursor >= self.portfolio_rows.items.len) return; const row = self.portfolio_rows.items[self.cursor]; switch (row.kind) { .position => { // Single-lot positions don't expand if (row.lot_count <= 1) return; if (row.pos_idx < self.expanded.len) { self.expanded[row.pos_idx] = !self.expanded[row.pos_idx]; self.rebuildPortfolioRows(); } }, .lot, .option_row, .cd_row, .cash_row, .illiquid_row, .section_header, .drip_summary => {}, .cash_total => { self.cash_expanded = !self.cash_expanded; self.rebuildPortfolioRows(); }, .illiquid_total => { self.illiquid_expanded = !self.illiquid_expanded; self.rebuildPortfolioRows(); }, .watchlist => { self.setActiveSymbol(row.symbol); self.active_tab = .quote; self.loadTabData(); }, } } fn toggleOptionsExpand(self: *App) void { if (self.options_rows.items.len == 0) return; if (self.options_cursor >= self.options_rows.items.len) return; const row = self.options_rows.items[self.options_cursor]; switch (row.kind) { .expiration => { if (row.exp_idx < self.options_expanded.len) { self.options_expanded[row.exp_idx] = !self.options_expanded[row.exp_idx]; self.rebuildOptionsRows(); } }, .calls_header => { if (row.exp_idx < self.options_calls_collapsed.len) { self.options_calls_collapsed[row.exp_idx] = !self.options_calls_collapsed[row.exp_idx]; self.rebuildOptionsRows(); } }, .puts_header => { if (row.exp_idx < self.options_puts_collapsed.len) { self.options_puts_collapsed[row.exp_idx] = !self.options_puts_collapsed[row.exp_idx]; self.rebuildOptionsRows(); } }, // Clicking on a contract does nothing else => {}, } } /// Toggle all calls (is_calls=true) or all puts (is_calls=false) collapsed state. fn toggleAllCallsPuts(self: *App, is_calls: bool) void { const chains = self.options_data orelse return; // Determine whether to collapse or expand: if any expanded chain has this section visible, collapse all; otherwise expand all var any_visible = false; for (chains, 0..) |_, ci| { if (ci >= self.options_expanded.len) break; if (!self.options_expanded[ci]) continue; // only count expanded expirations if (is_calls) { if (ci < self.options_calls_collapsed.len and !self.options_calls_collapsed[ci]) { any_visible = true; break; } } else { if (ci < self.options_puts_collapsed.len and !self.options_puts_collapsed[ci]) { any_visible = true; break; } } } // If any are visible, collapse all; otherwise expand all const new_state = any_visible; for (chains, 0..) |_, ci| { if (ci >= 64) break; if (is_calls) { self.options_calls_collapsed[ci] = new_state; } else { self.options_puts_collapsed[ci] = new_state; } } self.rebuildOptionsRows(); if (is_calls) { self.setStatus(if (new_state) "All calls collapsed" else "All calls expanded"); } else { self.setStatus(if (new_state) "All puts collapsed" else "All puts expanded"); } } fn rebuildOptionsRows(self: *App) void { self.options_rows.clearRetainingCapacity(); const chains = self.options_data orelse return; const atm_price = if (chains.len > 0) chains[0].underlying_price orelse 0 else @as(f64, 0); for (chains, 0..) |chain, ci| { self.options_rows.append(self.allocator, .{ .kind = .expiration, .exp_idx = ci, }) catch continue; if (ci < self.options_expanded.len and self.options_expanded[ci]) { // Calls header (always shown, acts as toggle) self.options_rows.append(self.allocator, .{ .kind = .calls_header, .exp_idx = ci, }) catch continue; // Calls contracts (only if not collapsed) if (!(ci < self.options_calls_collapsed.len and self.options_calls_collapsed[ci])) { const filtered_calls = fmt.filterNearMoney(chain.calls, atm_price, self.options_near_the_money); for (filtered_calls) |cc| { self.options_rows.append(self.allocator, .{ .kind = .call, .exp_idx = ci, .contract = cc, }) catch continue; } } // Puts header (always shown, acts as toggle) self.options_rows.append(self.allocator, .{ .kind = .puts_header, .exp_idx = ci, }) catch continue; // Puts contracts (only if not collapsed) if (!(ci < self.options_puts_collapsed.len and self.options_puts_collapsed[ci])) { const filtered_puts = fmt.filterNearMoney(chain.puts, atm_price, self.options_near_the_money); for (filtered_puts) |p| { self.options_rows.append(self.allocator, .{ .kind = .put, .exp_idx = ci, .contract = p, }) catch continue; } } } } } fn ensureOptionsCursorVisible(self: *App) void { const cursor_row = self.options_cursor + self.options_header_lines; if (cursor_row < self.scroll_offset) { self.scroll_offset = cursor_row; } const vis: usize = self.visible_height; if (cursor_row >= self.scroll_offset + vis) { self.scroll_offset = cursor_row - vis + 1; } } pub fn setActiveSymbol(self: *App, sym: []const u8) void { const len = @min(sym.len, self.symbol_buf.len); @memcpy(self.symbol_buf[0..len], sym[0..len]); for (self.symbol_buf[0..len]) |*c| c.* = std.ascii.toUpper(c.*); self.symbol = self.symbol_buf[0..len]; self.symbol_owned = true; self.has_explicit_symbol = true; self.resetSymbolData(); } fn resetSymbolData(self: *App) void { self.perf_loaded = false; self.earnings_loaded = false; self.earnings_disabled = false; self.options_loaded = false; self.etf_loaded = false; 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.options_rows.clearRetainingCapacity(); self.candle_timestamp = 0; self.options_timestamp = 0; self.earnings_timestamp = 0; self.quote = null; self.quote_timestamp = 0; self.freeCandles(); self.freeDividends(); self.freeEarnings(); self.freeOptions(); self.freeEtfProfile(); self.trailing_price = null; self.trailing_total = null; self.trailing_me_price = null; self.trailing_me_total = null; self.risk_metrics = null; self.scroll_offset = 0; self.chart.dirty = true; } fn refreshCurrentTab(self: *App) void { // Invalidate cache so the next load forces a fresh fetch if (self.symbol.len > 0) { switch (self.active_tab) { .quote, .performance => { self.svc.invalidate(self.symbol, .candles_daily); self.svc.invalidate(self.symbol, .dividends); }, .earnings => { self.svc.invalidate(self.symbol, .earnings); }, .options => { self.svc.invalidate(self.symbol, .options); }, .portfolio, .analysis => {}, } } switch (self.active_tab) { .portfolio => { self.portfolio_loaded = false; self.freePortfolioSummary(); }, .quote, .performance => { self.perf_loaded = false; self.freeCandles(); self.freeDividends(); self.chart.dirty = true; }, .earnings => { self.earnings_loaded = false; self.freeEarnings(); }, .options => { self.options_loaded = false; self.freeOptions(); }, .analysis => { self.analysis_loaded = false; if (self.analysis_result) |*ar| ar.deinit(self.allocator); self.analysis_result = null; if (self.account_map) |*am| am.deinit(); self.account_map = null; }, } self.loadTabData(); // After reload, fetch live quote for active symbol (costs 1 API call) switch (self.active_tab) { .quote, .performance => { if (self.symbol.len > 0) { if (self.svc.getQuote(self.symbol)) |q| { self.quote = q; self.quote_timestamp = std.time.timestamp(); } else |_| {} } }, else => {}, } } fn loadTabData(self: *App) void { self.data_error = null; switch (self.active_tab) { .portfolio => { if (!self.portfolio_loaded) self.loadPortfolioData(); }, .quote, .performance => { if (self.symbol.len == 0) return; if (!self.perf_loaded) self.loadPerfData(); }, .earnings => { if (self.symbol.len == 0) return; if (self.earnings_disabled) return; if (!self.earnings_loaded) self.loadEarningsData(); }, .options => { if (self.symbol.len == 0) return; if (!self.options_loaded) self.loadOptionsData(); }, .analysis => { if (!self.analysis_loaded) self.loadAnalysisData(); }, } } pub fn loadPortfolioData(self: *App) void { portfolio_tab.loadPortfolioData(self); } fn sortPortfolioAllocations(self: *App) void { portfolio_tab.sortPortfolioAllocations(self); } fn rebuildPortfolioRows(self: *App) void { portfolio_tab.rebuildPortfolioRows(self); } pub fn loadPerfData(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; const candle_result = self.svc.getCandles(self.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"), } return; }; self.candles = candle_result.data; self.candle_timestamp = candle_result.timestamp; const c = self.candles.?; if (c.len == 0) { self.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; const today = fmt.todayDate(); self.trailing_price = zfin.performance.trailingReturns(c); self.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); } else |_| {} self.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 (etf_result.data.isEtf()) { self.etf_profile = etf_result.data; } } else |_| {} } self.setStatus(if (candle_result.source == .cached) "r/F5 to refresh" else "Fetched | r/F5 to refresh"); } fn loadEarningsData(self: *App) void { self.earnings_loaded = true; self.freeEarnings(); const result = self.svc.getEarnings(self.symbol) catch |err| { switch (err) { zfin.DataError.NoApiKey => self.setStatus("No API key. Set FINNHUB_API_KEY"), zfin.DataError.FetchFailed => { self.earnings_disabled = true; self.setStatus("No earnings data (ETF/index?)"); }, else => self.setStatus("Error loading earnings"), } return; }; self.earnings_data = result.data; self.earnings_timestamp = result.timestamp; if (result.data.len == 0) { self.earnings_disabled = true; self.setStatus("No earnings data available (ETF/index?)"); return; } self.setStatus(if (result.source == .cached) "r/F5 to refresh" else "Fetched | r/F5 to refresh"); } fn loadOptionsData(self: *App) void { self.options_loaded = true; self.freeOptions(); const result = self.svc.getOptions(self.symbol) catch |err| { switch (err) { zfin.DataError.FetchFailed => self.setStatus("CBOE fetch failed (network error)"), else => self.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"); } 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]); self.status_len = len; } fn getStatus(self: *App) []const u8 { if (self.status_len == 0) return "h/l tabs | j/k select | Enter expand | s select | / symbol | ? help"; return self.status_msg[0..self.status_len]; } pub fn freeCandles(self: *App) void { if (self.candles) |c| self.allocator.free(c); self.candles = null; } pub fn freeDividends(self: *App) void { if (self.dividends) |d| zfin.Dividend.freeSlice(self.allocator, d); self.dividends = null; } pub fn freeEarnings(self: *App) void { if (self.earnings_data) |e| self.allocator.free(e); self.earnings_data = null; } pub fn freeOptions(self: *App) void { if (self.options_data) |chains| { for (chains) |chain| { self.allocator.free(chain.calls); self.allocator.free(chain.puts); self.allocator.free(chain.underlying_symbol); } self.allocator.free(chains); } self.options_data = null; } pub fn freeEtfProfile(self: *App) void { if (self.etf_profile) |profile| { if (profile.holdings) |h| { for (h) |holding| { if (holding.symbol) |s| self.allocator.free(s); self.allocator.free(holding.name); } self.allocator.free(h); } if (profile.sectors) |s| { for (s) |sec| self.allocator.free(sec.name); self.allocator.free(s); } } self.etf_profile = null; self.etf_loaded = false; } 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.freeCandles(); self.freeDividends(); self.freeEarnings(); self.freeOptions(); self.freeEtfProfile(); self.freePortfolioSummary(); self.portfolio_rows.deinit(self.allocator); self.options_rows.deinit(self.allocator); if (self.watchlist_prices) |*wp| wp.deinit(); if (self.analysis_result) |*ar| ar.deinit(self.allocator); if (self.classification_map) |*cm| cm.deinit(); if (self.account_map) |*am| am.deinit(); } fn reloadFiles(self: *App) void { // Reload portfolio 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 null; if (file_data) |d| { defer self.allocator.free(d); if (zfin.cache.deserializePortfolio(self.allocator, d)) |pf| { self.portfolio = pf; } else |_| {} } } // Reload watchlist freeWatchlist(self.allocator, self.watchlist); self.watchlist = null; if (self.watchlist_path) |path| { self.watchlist = loadWatchlist(self.allocator, path); } // Reset portfolio view state self.portfolio_loaded = false; self.freePortfolioSummary(); self.expanded = [_]bool{false} ** 64; self.cursor = 0; self.scroll_offset = 0; self.portfolio_rows.clearRetainingCapacity(); } /// Reload portfolio file from disk without re-fetching prices. /// Uses cached candle data to recompute summary. fn reloadPortfolioFile(self: *App) void { portfolio_tab.reloadPortfolioFile(self); } // ── Drawing ────────────────────────────────────────────────── fn typeErasedDrawFn(ptr: *anyopaque, ctx: vaxis.vxfw.DrawContext) std.mem.Allocator.Error!vaxis.vxfw.Surface { const self: *App = @ptrCast(@alignCast(ptr)); const max_size = ctx.max.size(); if (max_size.height < 3) { return .{ .size = max_size, .widget = self.widget(), .buffer = &.{}, .children = &.{} }; } self.visible_height = max_size.height -| 2; var children: std.ArrayList(vaxis.vxfw.SubSurface) = .empty; const tab_surface = try self.drawTabBar(ctx, max_size.width); try children.append(ctx.arena, .{ .origin = .{ .row = 0, .col = 0 }, .surface = tab_surface }); const content_height = max_size.height - 2; const content_surface = try self.drawContent(ctx, max_size.width, content_height); try children.append(ctx.arena, .{ .origin = .{ .row = 1, .col = 0 }, .surface = content_surface }); const status_surface = try self.drawStatusBar(ctx, max_size.width); try children.append(ctx.arena, .{ .origin = .{ .row = @intCast(max_size.height - 1), .col = 0 }, .surface = status_surface }); return .{ .size = max_size, .widget = self.widget(), .buffer = &.{}, .children = try children.toOwnedSlice(ctx.arena) }; } fn drawTabBar(self: *App, ctx: vaxis.vxfw.DrawContext, width: u16) !vaxis.vxfw.Surface { const th = self.theme; const buf = try ctx.arena.alloc(vaxis.Cell, width); const inactive_style = th.tabStyle(); @memset(buf, .{ .char = .{ .grapheme = " " }, .style = inactive_style }); var col: usize = 0; for (tabs) |t| { const lbl = t.label(); const is_active = t == self.active_tab; const is_disabled = t == .earnings and self.earnings_disabled; const tab_style: vaxis.Style = if (is_active) th.tabActiveStyle() else if (is_disabled) th.tabDisabledStyle() else inactive_style; for (lbl) |ch| { if (col >= width) break; buf[col] = .{ .char = .{ .grapheme = glyph(ch) }, .style = tab_style }; col += 1; } } // Right-align the active symbol if set if (self.symbol.len > 0) { const is_selected = self.isSymbolSelected(); const prefix: []const u8 = if (is_selected) " * " else " "; const sym_label = try std.fmt.allocPrint(ctx.arena, "{s}{s} ", .{ prefix, self.symbol }); if (width > sym_label.len + col) { const sym_start = width - sym_label.len; const sym_style: vaxis.Style = .{ .fg = theme_mod.Theme.vcolor(if (is_selected) th.warning else th.info), .bg = theme_mod.Theme.vcolor(th.tab_bg), .bold = is_selected, }; for (0..sym_label.len) |i| { buf[sym_start + i] = .{ .char = .{ .grapheme = glyph(sym_label[i]) }, .style = sym_style }; } } } return .{ .size = .{ .width = width, .height = 1 }, .widget = self.widget(), .buffer = buf, .children = &.{} }; } fn isSymbolSelected(self: *App) bool { // Symbol is "selected" if it matches a portfolio/watchlist row the user explicitly selected with 's' if (self.active_tab != .portfolio) return false; if (self.portfolio_rows.items.len == 0) return false; if (self.cursor >= self.portfolio_rows.items.len) return false; return std.mem.eql(u8, self.portfolio_rows.items[self.cursor].symbol, self.symbol); } fn drawContent(self: *App, ctx: vaxis.vxfw.DrawContext, width: u16, height: u16) !vaxis.vxfw.Surface { const th = self.theme; const content_style = th.contentStyle(); const buf_size: usize = @as(usize, width) * height; const buf = try ctx.arena.alloc(vaxis.Cell, buf_size); @memset(buf, .{ .char = .{ .grapheme = " " }, .style = content_style }); 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 => try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildPerfStyledLines(ctx.arena)), .options => try self.drawOptionsContent(ctx.arena, buf, width, height), .earnings => try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildEarningsStyledLines(ctx.arena)), .analysis => try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildAnalysisStyledLines(ctx.arena)), } } return .{ .size = .{ .width = width, .height = height }, .widget = self.widget(), .buffer = buf, .children = &.{} }; } 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; // Fill row with style bg for (0..width) |ci| { buf[row * width + ci] = .{ .char = .{ .grapheme = " " }, .style = line.style }; } // Grapheme-based rendering (for braille / multi-byte Unicode lines) if (line.graphemes) |graphemes| { const cell_styles = line.cell_styles; for (0..@min(graphemes.len, width)) |ci| { const s = if (cell_styles) |cs| cs[ci] else line.style; buf[row * width + ci] = .{ .char = .{ .grapheme = graphemes[ci] }, .style = s }; } } else { // UTF-8 aware rendering: byte index and column index tracked separately var col: usize = 0; var bi: usize = 0; while (bi < line.text.len and col < width) { var s = line.style; if (line.alt_style) |alt| { if (col >= line.alt_start and col < line.alt_end) s = alt; } const byte = line.text[bi]; if (byte < 0x80) { // ASCII: single byte, single column buf[row * width + col] = .{ .char = .{ .grapheme = ascii_g[byte] }, .style = s }; bi += 1; } else { // Multi-byte UTF-8: determine sequence length const seq_len: usize = if (byte >= 0xF0) 4 else if (byte >= 0xE0) 3 else if (byte >= 0xC0) 2 else 1; const end = @min(bi + seq_len, line.text.len); buf[row * width + col] = .{ .char = .{ .grapheme = line.text[bi..end] }, .style = s }; bi = end; } col += 1; } } } } fn drawStatusBar(self: *App, ctx: vaxis.vxfw.DrawContext, width: u16) !vaxis.vxfw.Surface { const t = self.theme; const buf = try ctx.arena.alloc(vaxis.Cell, width); if (self.mode == .symbol_input) { const prompt_style = t.inputStyle(); @memset(buf, .{ .char = .{ .grapheme = " " }, .style = prompt_style }); const prompt = "Symbol: "; for (0..@min(prompt.len, width)) |i| { buf[i] = .{ .char = .{ .grapheme = glyph(prompt[i]) }, .style = prompt_style }; } const input = self.input_buf[0..self.input_len]; for (0..@min(input.len, @as(usize, width) -| prompt.len)) |i| { buf[prompt.len + i] = .{ .char = .{ .grapheme = glyph(input[i]) }, .style = prompt_style }; } const cursor_pos = prompt.len + self.input_len; if (cursor_pos < width) { var cursor_style = prompt_style; cursor_style.blink = true; buf[cursor_pos] = .{ .char = .{ .grapheme = "_" }, .style = cursor_style }; } const hint = " Enter=confirm Esc=cancel "; if (width > hint.len + cursor_pos + 2) { const hint_start = width - hint.len; const hint_style = t.inputHintStyle(); for (0..hint.len) |i| { buf[hint_start + i] = .{ .char = .{ .grapheme = glyph(hint[i]) }, .style = hint_style }; } } } else { const status_style = t.statusStyle(); @memset(buf, .{ .char = .{ .grapheme = " " }, .style = status_style }); const msg = self.getStatus(); for (0..@min(msg.len, width)) |i| { buf[i] = .{ .char = .{ .grapheme = glyph(msg[i]) }, .style = status_style }; } } return .{ .size = .{ .width = width, .height = 1 }, .widget = self.widget(), .buffer = buf, .children = &.{} }; } // ── Portfolio content ───────────────────────────────────────── fn drawPortfolioContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { return portfolio_tab.drawContent(self, arena, buf, width, height); } // ── 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 options_tab.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 quote_tab.drawContent(self, ctx, buf, width, height); } // ── Performance tab ────────────────────────────────────────── fn buildPerfStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { return perf_tab.buildStyledLines(self, arena); } // ── Earnings tab ───────────────────────────────────────────── fn buildEarningsStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { return earnings_tab.buildStyledLines(self, arena); } // ── Analysis tab ──────────────────────────────────────────── pub fn loadAnalysisData(self: *App) void { analysis_tab.loadData(self); } fn buildAnalysisStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { return analysis_tab.buildStyledLines(self, arena); } // ── Help ───────────────────────────────────────────────────── fn buildHelpStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { const th = self.theme; var lines: std.ArrayList(StyledLine) = .empty; try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = " zfin TUI -- Keybindings", .style = th.headerStyle() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); const actions = comptime std.enums.values(keybinds.Action); const action_labels = [_][]const u8{ "Quit", "Refresh", "Previous tab", "Next tab", "Tab 1", "Tab 2", "Tab 3", "Tab 4", "Tab 5", "Tab 6", "Scroll down", "Scroll up", "Scroll to top", "Scroll to bottom", "Page down", "Page up", "Select next", "Select prev", "Expand/collapse", "Select symbol", "Change symbol (search)", "This help", "Edit portfolio/watchlist", "Reload portfolio from disk", "Toggle all calls (options)", "Toggle all puts (options)", "Filter +/- 1 NTM", "Filter +/- 2 NTM", "Filter +/- 3 NTM", "Filter +/- 4 NTM", "Filter +/- 5 NTM", "Filter +/- 6 NTM", "Filter +/- 7 NTM", "Filter +/- 8 NTM", "Filter +/- 9 NTM", "Chart: next timeframe", "Chart: prev timeframe", "Sort: next column", "Sort: prev column", "Sort: reverse order", }; for (actions, 0..) |action, ai| { var key_strs: [8][]const u8 = undefined; var key_count: usize = 0; for (self.keymap.bindings) |b| { if (b.action == action and key_count < key_strs.len) { var key_buf: [32]u8 = undefined; if (keybinds.formatKeyCombo(b.key, &key_buf)) |s| { key_strs[key_count] = try arena.dupe(u8, s); key_count += 1; } } } if (key_count == 0) continue; var combined_buf: [128]u8 = undefined; var pos: usize = 0; for (0..key_count) |ki| { if (ki > 0) { if (pos + 2 <= combined_buf.len) { combined_buf[pos] = ','; combined_buf[pos + 1] = ' '; pos += 2; } } const ks = key_strs[ki]; if (pos + ks.len <= combined_buf.len) { @memcpy(combined_buf[pos..][0..ks.len], ks); pos += ks.len; } } const label_text = if (ai < action_labels.len) action_labels[ai] else @tagName(action); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s}", .{ combined_buf[0..pos], label_text }), .style = th.contentStyle() }); } try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = " Mouse: click tabs, scroll wheel, click rows", .style = th.mutedStyle() }); try lines.append(arena, .{ .text = " Config: ~/.config/zfin/keys.srf | theme.srf", .style = th.mutedStyle() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = " Press any key to close.", .style = th.dimStyle() }); return lines.toOwnedSlice(arena); } // ── Tab navigation ─────────────────────────────────────────── fn nextTab(self: *App) void { const idx = @intFromEnum(self.active_tab); var next_idx = if (idx + 1 < tabs.len) idx + 1 else 0; if (tabs[next_idx] == .earnings and self.earnings_disabled) next_idx = if (next_idx + 1 < tabs.len) next_idx + 1 else 0; self.active_tab = tabs[next_idx]; } fn prevTab(self: *App) void { const idx = @intFromEnum(self.active_tab); var prev_idx = if (idx > 0) idx - 1 else tabs.len - 1; if (tabs[prev_idx] == .earnings and self.earnings_disabled) prev_idx = if (prev_idx > 0) prev_idx - 1 else tabs.len - 1; self.active_tab = tabs[prev_idx]; } }; // ── Utility functions ──────────────────────────────────────── pub fn renderBrailleToStyledLines(arena: std.mem.Allocator, lines: *std.ArrayList(StyledLine), data: []const zfin.Candle, th: theme_mod.Theme) !void { var chart = fmt.computeBrailleChart(arena, data, 60, 10, th.positive, th.negative) catch return; // No deinit needed: arena handles cleanup const bg = th.bg; for (0..chart.chart_height) |row| { const graphemes = try arena.alloc([]const u8, chart.n_cols + 12); // chart + padding + label const styles = try arena.alloc(vaxis.Style, chart.n_cols + 12); var gpos: usize = 0; // 2 leading spaces graphemes[gpos] = " "; styles[gpos] = .{ .fg = theme_mod.Theme.vcolor(th.text_muted), .bg = theme_mod.Theme.vcolor(bg) }; gpos += 1; graphemes[gpos] = " "; styles[gpos] = styles[0]; gpos += 1; // Chart columns for (0..chart.n_cols) |col| { const pattern = chart.pattern(row, col); graphemes[gpos] = fmt.brailleGlyph(pattern); if (pattern != 0) { styles[gpos] = .{ .fg = theme_mod.Theme.vcolor(chart.col_colors[col]), .bg = theme_mod.Theme.vcolor(bg) }; } else { styles[gpos] = .{ .fg = theme_mod.Theme.vcolor(bg), .bg = theme_mod.Theme.vcolor(bg) }; } gpos += 1; } // Right-side price labels if (row == 0) { const lbl = try std.fmt.allocPrint(arena, " {s}", .{chart.maxLabel()}); for (lbl) |ch| { if (gpos < graphemes.len) { graphemes[gpos] = glyph(ch); styles[gpos] = .{ .fg = theme_mod.Theme.vcolor(th.text_muted), .bg = theme_mod.Theme.vcolor(bg) }; gpos += 1; } } } else if (row == chart.chart_height - 1) { const lbl = try std.fmt.allocPrint(arena, " {s}", .{chart.minLabel()}); for (lbl) |ch| { if (gpos < graphemes.len) { graphemes[gpos] = glyph(ch); styles[gpos] = .{ .fg = theme_mod.Theme.vcolor(th.text_muted), .bg = theme_mod.Theme.vcolor(bg) }; gpos += 1; } } } try lines.append(arena, .{ .text = "", .style = .{ .fg = theme_mod.Theme.vcolor(th.text), .bg = theme_mod.Theme.vcolor(bg) }, .graphemes = graphemes[0..gpos], .cell_styles = styles[0..gpos], }); } // Date axis below chart { var start_buf: [7]u8 = undefined; var end_buf: [7]u8 = undefined; const start_label = fmt.BrailleChart.fmtShortDate(chart.start_date, &start_buf); const end_label = fmt.BrailleChart.fmtShortDate(chart.end_date, &end_buf); const muted_style = vaxis.Style{ .fg = theme_mod.Theme.vcolor(th.text_muted), .bg = theme_mod.Theme.vcolor(bg) }; const date_graphemes = try arena.alloc([]const u8, chart.n_cols + 12); const date_styles = try arena.alloc(vaxis.Style, chart.n_cols + 12); var dpos: usize = 0; // 2 leading spaces date_graphemes[dpos] = " "; date_styles[dpos] = muted_style; dpos += 1; date_graphemes[dpos] = " "; date_styles[dpos] = muted_style; dpos += 1; // Start date label for (start_label) |ch| { if (dpos < date_graphemes.len) { date_graphemes[dpos] = glyph(ch); date_styles[dpos] = muted_style; dpos += 1; } } // Gap between labels const total_width = chart.n_cols; if (total_width > start_label.len + end_label.len) { const gap = total_width - start_label.len - end_label.len; for (0..gap) |_| { if (dpos < date_graphemes.len) { date_graphemes[dpos] = " "; date_styles[dpos] = muted_style; dpos += 1; } } } // End date label for (end_label) |ch| { if (dpos < date_graphemes.len) { date_graphemes[dpos] = glyph(ch); date_styles[dpos] = muted_style; dpos += 1; } } try lines.append(arena, .{ .text = "", .style = .{ .fg = theme_mod.Theme.vcolor(th.text), .bg = theme_mod.Theme.vcolor(bg) }, .graphemes = date_graphemes[0..dpos], .cell_styles = date_styles[0..dpos], }); } } pub fn loadWatchlist(allocator: std.mem.Allocator, path: []const u8) ?[][]const u8 { const file_data = std.fs.cwd().readFileAlloc(allocator, path, 1024 * 1024) catch return null; defer allocator.free(file_data); var syms: std.ArrayList([]const u8) = .empty; var file_lines = std.mem.splitScalar(u8, file_data, '\n'); while (file_lines.next()) |line| { const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace); if (trimmed.len == 0 or trimmed[0] == '#') continue; if (std.mem.indexOf(u8, trimmed, "symbol::")) |idx| { const rest = trimmed[idx + "symbol::".len ..]; const end = std.mem.indexOfScalar(u8, rest, ',') orelse rest.len; const sym = std.mem.trim(u8, rest[0..end], &std.ascii.whitespace); if (sym.len > 0 and sym.len <= 10) { const duped = allocator.dupe(u8, sym) catch continue; syms.append(allocator, duped) catch { allocator.free(duped); continue; }; } } } if (syms.items.len == 0) { syms.deinit(allocator); return null; } return syms.toOwnedSlice(allocator) catch null; } pub fn freeWatchlist(allocator: std.mem.Allocator, watchlist: ?[][]const u8) void { if (watchlist) |wl| { for (wl) |sym| allocator.free(sym); allocator.free(wl); } } // Force test discovery for imported TUI sub-modules comptime { _ = keybinds; _ = theme_mod; _ = portfolio_tab; _ = quote_tab; _ = perf_tab; _ = options_tab; _ = earnings_tab; _ = analysis_tab; } /// Entry point for the interactive TUI. pub fn run(allocator: std.mem.Allocator, config: zfin.Config, args: []const []const u8) !void { var portfolio_path: ?[]const u8 = null; var watchlist_path: ?[]const u8 = null; var symbol: []const u8 = ""; var symbol_upper_buf: [32]u8 = undefined; var has_explicit_symbol = false; var skip_watchlist = false; var chart_config: chart_mod.ChartConfig = .{}; var i: usize = 2; while (i < args.len) : (i += 1) { if (std.mem.eql(u8, args[i], "--default-keys")) { try keybinds.printDefaults(); return; } else if (std.mem.eql(u8, args[i], "--default-theme")) { try theme_mod.printDefaults(); return; } else if (std.mem.eql(u8, args[i], "--portfolio") or std.mem.eql(u8, args[i], "-p")) { if (i + 1 < args.len) { i += 1; portfolio_path = args[i]; } } else if (std.mem.eql(u8, args[i], "--watchlist") or std.mem.eql(u8, args[i], "-w")) { if (i + 1 < args.len) { i += 1; watchlist_path = args[i]; } else { watchlist_path = "watchlist.srf"; } } else if (std.mem.eql(u8, args[i], "--symbol") or std.mem.eql(u8, args[i], "-s")) { if (i + 1 < args.len) { i += 1; const len = @min(args[i].len, symbol_upper_buf.len); _ = std.ascii.upperString(symbol_upper_buf[0..len], args[i][0..len]); symbol = symbol_upper_buf[0..len]; has_explicit_symbol = true; skip_watchlist = true; } } else if (std.mem.eql(u8, args[i], "--chart")) { if (i + 1 < args.len) { i += 1; if (chart_mod.ChartConfig.parse(args[i])) |cc| { chart_config = cc; } } } else if (args[i].len > 0 and args[i][0] != '-') { const len = @min(args[i].len, symbol_upper_buf.len); _ = std.ascii.upperString(symbol_upper_buf[0..len], args[i][0..len]); symbol = symbol_upper_buf[0..len]; has_explicit_symbol = true; } } if (portfolio_path == null and !has_explicit_symbol) { if (std.fs.cwd().access("portfolio.srf", .{})) |_| { portfolio_path = "portfolio.srf"; } else |_| {} } var keymap = blk: { const home = std.posix.getenv("HOME") orelse break :blk keybinds.defaults(); const keys_path = std.fs.path.join(allocator, &.{ home, ".config", "zfin", "keys.srf" }) catch break :blk keybinds.defaults(); defer allocator.free(keys_path); break :blk keybinds.loadFromFile(allocator, keys_path) orelse keybinds.defaults(); }; defer keymap.deinit(); const theme = blk: { const home = std.posix.getenv("HOME") orelse break :blk theme_mod.default_theme; const theme_path = std.fs.path.join(allocator, &.{ home, ".config", "zfin", "theme.srf" }) catch break :blk theme_mod.default_theme; defer allocator.free(theme_path); break :blk theme_mod.loadFromFile(allocator, theme_path) orelse theme_mod.default_theme; }; var svc = try allocator.create(zfin.DataService); defer allocator.destroy(svc); svc.* = zfin.DataService.init(allocator, config); defer svc.deinit(); var app_inst = try allocator.create(App); defer allocator.destroy(app_inst); app_inst.* = .{ .allocator = allocator, .config = config, .svc = svc, .keymap = keymap, .theme = theme, .portfolio_path = portfolio_path, .symbol = symbol, .has_explicit_symbol = has_explicit_symbol, .chart = .{ .config = chart_config }, }; if (portfolio_path) |path| { const file_data = std.fs.cwd().readFileAlloc(allocator, path, 10 * 1024 * 1024) catch null; if (file_data) |d| { defer allocator.free(d); if (zfin.cache.deserializePortfolio(allocator, d)) |pf| { app_inst.portfolio = pf; } else |_| {} } } if (!skip_watchlist) { const wl_path = watchlist_path orelse blk: { std.fs.cwd().access("watchlist.srf", .{}) catch break :blk null; break :blk @as(?[]const u8, "watchlist.srf"); }; if (wl_path) |path| { app_inst.watchlist = loadWatchlist(allocator, path); app_inst.watchlist_path = path; } } if (has_explicit_symbol and symbol.len > 0) { app_inst.active_tab = .quote; } // Pre-fetch portfolio prices before TUI starts, with stderr progress. // This runs while the terminal is still in normal mode so output is visible. if (app_inst.portfolio) |pf| { const syms = pf.stockSymbols(allocator) catch null; defer if (syms) |s| allocator.free(s); // Collect watchlist symbols var watch_syms: std.ArrayList([]const u8) = .empty; defer watch_syms.deinit(allocator); { var seen = std.StringHashMap(void).init(allocator); defer seen.deinit(); if (syms) |ss| for (ss) |s| seen.put(s, {}) catch {}; if (app_inst.watchlist) |wl| { for (wl) |sym_w| { if (!seen.contains(sym_w)) { seen.put(sym_w, {}) catch {}; watch_syms.append(allocator, sym_w) catch {}; } } } for (pf.lots) |lot| { if (lot.security_type == .watch and !seen.contains(lot.priceSymbol())) { seen.put(lot.priceSymbol(), {}) catch {}; watch_syms.append(allocator, lot.priceSymbol()) catch {}; } } } const stock_count = if (syms) |ss| ss.len else 0; const total_count = stock_count + watch_syms.items.len; if (total_count > 0) { var prices = std.StringHashMap(f64).init(allocator); var progress = cli.LoadProgress{ .svc = svc, .color = true, .index_offset = 0, .grand_total = total_count, }; if (syms) |ss| { const result = svc.loadPrices(ss, &prices, false, progress.callback()); progress.index_offset = stock_count; if (result.fetched_count > 0 or result.fail_count > 0) { var msg_buf: [256]u8 = undefined; const msg = std.fmt.bufPrint(&msg_buf, "Loaded {d} symbols ({d} cached, {d} fetched, {d} failed)\n", .{ ss.len, result.cached_count, result.fetched_count, result.fail_count }) catch "Done loading\n"; cli.stderrPrint(msg) catch {}; } } // Load watchlist prices if (watch_syms.items.len > 0) { _ = svc.loadPrices(watch_syms.items, &prices, false, progress.callback()); } app_inst.prefetched_prices = prices; } } defer if (app_inst.portfolio) |*pf| pf.deinit(); defer freeWatchlist(allocator, app_inst.watchlist); defer app_inst.deinitData(); while (true) { { var vx_app = try vaxis.vxfw.App.init(allocator); defer vx_app.deinit(); app_inst.vx_app = &vx_app; defer app_inst.vx_app = null; defer { // Free any chart image before vaxis is torn down if (app_inst.chart.image_id) |id| { vx_app.vx.freeImage(vx_app.tty.writer(), id); app_inst.chart.image_id = null; } } try vx_app.run(app_inst.widget(), .{}); } // vx_app is fully torn down here (terminal restored to cooked mode) if (!app_inst.wants_edit) break; app_inst.wants_edit = false; launchEditor(allocator, app_inst.portfolio_path, app_inst.watchlist_path); app_inst.reloadFiles(); app_inst.active_tab = .portfolio; } } /// Launch $EDITOR on the portfolio and/or watchlist files. fn launchEditor(allocator: std.mem.Allocator, portfolio_path: ?[]const u8, watchlist_path: ?[]const u8) void { const editor = std.posix.getenv("EDITOR") orelse std.posix.getenv("VISUAL") orelse "vi"; var argv_buf: [4][]const u8 = undefined; var argc: usize = 0; argv_buf[argc] = editor; argc += 1; if (portfolio_path) |p| { argv_buf[argc] = p; argc += 1; } if (watchlist_path) |p| { argv_buf[argc] = p; argc += 1; } const argv = argv_buf[0..argc]; var child = std.process.Child.init(argv, allocator); child.spawn() catch return; _ = child.wait() catch {}; } // ── Tests ───────────────────────────────────────────────────────────── const testing = std.testing; test "colLabel plain left-aligned" { var buf: [32]u8 = undefined; const result = colLabel(&buf, "Name", 10, true, null); try testing.expectEqualStrings("Name ", result); try testing.expectEqual(@as(usize, 10), result.len); } test "colLabel plain right-aligned" { var buf: [32]u8 = undefined; const result = colLabel(&buf, "Price", 10, false, null); try testing.expectEqualStrings(" Price", result); } test "colLabel with indicator left-aligned" { var buf: [64]u8 = undefined; const result = colLabel(&buf, "Name", 10, true, "\xe2\x96\xb2"); // ▲ = 3 bytes // Indicator + text + padding. Display width is 10, byte length is 10 - 1 + 3 = 12 try testing.expectEqual(@as(usize, 12), result.len); try testing.expect(std.mem.startsWith(u8, result, "\xe2\x96\xb2")); // starts with ▲ try testing.expect(std.mem.indexOf(u8, result, "Name") != null); } test "colLabel with indicator right-aligned" { var buf: [64]u8 = undefined; const result = colLabel(&buf, "Price", 10, false, "\xe2\x96\xbc"); // ▼ try testing.expectEqual(@as(usize, 12), result.len); try testing.expect(std.mem.endsWith(u8, result, "Price")); } test "glyph ASCII returns single-char slice" { try testing.expectEqualStrings("A", glyph('A')); try testing.expectEqualStrings(" ", glyph(' ')); try testing.expectEqualStrings("0", glyph('0')); } 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 ", Tab.portfolio.label()); try testing.expectEqualStrings(" 6:Analysis ", Tab.analysis.label()); }