diff --git a/AGENTS.md b/AGENTS.md index e1ac58f..6ccdae1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -255,6 +255,31 @@ Ask the user instead.** on files with uncommitted work, unless the user asks for that specific operation by name. +### NEVER run `git stash`. EVER. + +- **`git stash` is banned outright.** Not `git stash push`, not `git stash --keep-index`, + not `git stash pop`, not `git stash apply`, not even read-only `git stash list`. + No variant. No "just to test something." No "I'll pop it right back." +- The reason: `git stash pop` can conflict on overlapping lines and leave + unresolved conflict markers in the working tree. The recovery requires + hand-resolving the markers, which trashes whatever curated index state + the user had in flight. This has bitten the user before and it's bitten + them again because of me. The rule is absolute now. +- If you think you need `git stash` to verify something (e.g. "does the + staged-only state build cleanly in isolation?"), the answer is: **don't + verify it that way.** Either: + - Read `git diff --cached` and reason about whether the staged hunks + are coherent on their own, OR + - Ask the user to verify after they commit, OR + - If verification is critical, ask the user to do the stash themselves + with their tools — but recommend against it because of this rule. +- **There is no exception clause for `git stash`.** Not even "the user + said it's OK this once" — the previous "one-time exception" for git + staging operations is what led to the stash incident. Direct exceptions + for `git add -p` / `git restore --staged` for staging-management remain + permitted with explicit user approval, but `git stash` is permanently + off-limits regardless of consent. + ### NEVER run `git add`, `git commit`, or `git push`. EVER. - **The user commits. You do not.** Do not stage files. Do not create commits. diff --git a/src/tui.zig b/src/tui.zig index 8a6cc28..f5e1438 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -250,7 +250,6 @@ pub const OptionsRow = struct { }; pub const ChartState = struct { - config: chart.ChartConfig = .{}, timeframe: chart.Timeframe = .@"1Y", image_id: ?u32 = null, // currently transmitted Kitty image ID image_width: u16 = 0, // image width in cells @@ -315,6 +314,10 @@ pub const ChartState = struct { /// `TODO.md`'s "Refactor: TUI tab framework" entry. pub const TabStates = struct { earnings: earnings_tab.State = .{}, + analysis: analysis_tab.State = .{}, + quote: quote_tab.State = .{}, + performance: perf_tab.State = .{}, + options: options_tab.State = .{}, }; /// Comptime registry of all tab modules conforming to the @@ -347,6 +350,10 @@ pub const TabStates = struct { /// proven out tab-by-tab. const tab_modules = .{ .earnings = earnings_tab, + .analysis = analysis_tab, + .quote = quote_tab, + .performance = perf_tab, + .options = options_tab, }; comptime { @@ -365,6 +372,16 @@ comptime { /// single tab doesn't own this data — it's a shared cache scoped /// to "the current symbol." Cleared in `resetSymbolData` whenever /// the user changes symbols. +/// +/// **Cross-tab mutation note.** Today consumers re-read these +/// fields on every render frame, so mutations don't need +/// notification. If a future tab needs to react to a mutation +/// (e.g. recompute a derived value when `candles` changes rather +/// than re-derive on every render), the right escape valve is a +/// new framework lifecycle hook (e.g. `onSymbolDataChange`) that +/// tabs opt into via `@hasDecl`. We're not adding it speculatively +/// — the read-on-render pattern is sufficient for current +/// consumers. pub const SymbolData = struct { /// Daily OHLCV candles for the active symbol, oldest-first. /// Owned by SymbolData; freed via `deinit` or `clear`. @@ -472,6 +489,17 @@ pub const PortfolioData = struct { /// Account-tax-type metadata loaded from `accounts.srf` next /// to the portfolio. Used by analysis (tax-type breakdown) /// and portfolio (per-account display). + /// + /// **Cross-tab mutation note.** Analysis-tab refresh + /// (`analysis_tab.tab.reload`) clears this field so the next + /// load re-reads `accounts.srf` from disk (the user may have + /// edited it). Portfolio-tab consumers re-read this field on + /// every render, so the clear-and-reload doesn't require a + /// notification today. If a future tab needs to react to the + /// clear (e.g. invalidate a cached aggregation), the right + /// escape valve is a new framework lifecycle hook + /// (e.g. `onAccountMapChange`) that tabs opt into via + /// `@hasDecl`. Not added speculatively. account_map: ?zfin.analysis.AccountMap = null, /// Cached prices for watchlist symbols (no live fetching during /// render). Populated on portfolio load and refresh. @@ -511,6 +539,15 @@ pub const App = struct { /// `app.states.`; tabs not yet migrated still have their /// fields directly on App. states: TabStates = .{}, + /// Per-symbol shared data (candles, dividends, trailing returns, + /// ETF profile). See `SymbolData` above. Cleared in + /// `resetSymbolData` on symbol change. + symbol_data: SymbolData = .{}, + /// Per-portfolio shared data (loaded portfolio file, summary, + /// account map, watchlist prices, historical snapshots). See + /// `PortfolioData` above. Reloaded by + /// `portfolio_tab.reloadPortfolioFile` on file changes. + portfolio: PortfolioData = .{}, /// Captured at App init and refreshed at tab change. Using a cached /// date (rather than calling the clock on every render) keeps render /// deterministic within a single frame and avoids threading `io` @@ -529,7 +566,6 @@ pub const App = struct { 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, @@ -554,7 +590,6 @@ pub const App = struct { 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) // Account filter state @@ -569,49 +604,6 @@ pub const App = struct { account_search_matches: std.ArrayList(usize) = .empty, // indices into account_list matching search account_search_cursor: usize = 0, // cursor within search_matches - // Options navigation (inline expand/collapse like portfolio) - options_cursor: usize = 0, // selected row in flattened options view - options_expanded: [64]bool = @splat(false), // which expirations are expanded - options_calls_collapsed: [64]bool = @splat(false), // per-expiration: calls section collapsed - options_puts_collapsed: [64]bool = @splat(false), // 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, - 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, - options_loaded: bool = false, - portfolio_loaded: bool = false, - // Data timestamps (unix seconds) - candle_timestamp: i64 = 0, - options_timestamp: i64 = 0, - // Stored real-time quote (only fetched on manual refresh) - quote: ?zfin.Quote = null, - quote_timestamp: i64 = 0, - // ETF profile (loaded lazily on quote tab) - etf_profile: ?zfin.EtfProfile = null, - etf_loaded: bool = false, - // Analysis tab state - analysis_result: ?zfin.analysis.AnalysisResult = null, - analysis_loaded: bool = false, - analysis_disabled: bool = false, // true when no portfolio loaded (analysis requires portfolio) - classification_map: ?zfin.classification.ClassificationMap = null, - account_map: ?zfin.analysis.AccountMap = null, - // History tab state history_loaded: bool = false, history_disabled: bool = false, // true when no portfolio path (history requires it) @@ -693,8 +685,11 @@ pub const App = struct { // Terminals often send multiple wheel events per physical tick. last_wheel_ns: i128 = 0, - // Chart state (Kitty graphics) - chart: ChartState = .{}, + /// Global chart-rendering config (mode, max dimensions). Driven + /// by the `--chart` CLI flag at startup; not per-tab. Consumed by + /// any tab that renders pixel charts (quote, projections, future + /// forecast-evaluation views). + chart_config: chart.ChartConfig = .{}, vx_app: ?*vaxis.vxfw.App = null, // set during run(), for Kitty graphics access pub fn widget(self: *App) vaxis.vxfw.Widget { @@ -802,6 +797,7 @@ pub const App = struct { } } // Portfolio tab: click header to sort, click row to expand/collapse + // Portfolio tab: click header to sort, click row to expand/collapse if (self.active_tab == .portfolio and mouse.row > 0) { const content_row = @as(usize, @intCast(mouse.row)) + self.scroll_offset; // Click on column header row -> sort by that column @@ -850,54 +846,11 @@ pub const App = struct { } } } - // 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.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; - } - } + // Framework dispatch: ask the active tab's `handleMouse` + // (when defined) if it wants to consume this click. Tabs + // without a `handleMouse` decl simply fall through. + if (self.dispatchBool("handleMouse", .{mouse})) { + return ctx.consumeAndRedraw(); } // History tab: click a tier header to expand/collapse; // click a bucket/snapshot row to move the cursor. @@ -920,6 +873,111 @@ pub const App = struct { } } + /// Does the active tab declare the named hook? Pure predicate; + /// no dispatch. Used to gate work that conditionally fires + /// alongside a hook (e.g. wheel-debounce only for cursor-bearing + /// tabs in `moveBy`). + fn activeTabHas(self: *const App, comptime hook_name: []const u8) bool { + 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); + return @hasDecl(Module.tab, hook_name); + } + } + return false; + } + + /// Dispatch to a `bool`-returning optional hook on the active + /// tab. Returns the hook's result, or `false` if the active tab + /// doesn't declare the hook (so callers can treat "not declared" + /// the same as "declined to consume"). + /// + /// `args` is a tuple of the trailing arguments after `(state, app)` + /// — for `handleMouse`, that's `.{mouse}`; for `onCursorMove`, + /// `.{delta}`. The validator in `tab_framework` already enforces + /// each hook's full signature at comptime, so a typo'd `hook_name` + /// or wrong arg shape is caught when the registered tab module is + /// checked at module-init time. + /// + /// `anytype` is justified here: this is generic dispatch over a + /// closed set of hook signatures whose shapes are independently + /// validated by `tab_framework.validateTabModule`. The framework + /// IS the type contract; this dispatcher is the runtime accessor. + fn dispatchBool(self: *App, comptime hook_name: []const u8, args: anytype) bool { + 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); + if (!@hasDecl(Module.tab, hook_name)) return false; + const fn_ptr = @field(Module.tab, hook_name); + const state_ptr = &@field(self.states, field.name); + return @call(.auto, fn_ptr, .{ state_ptr, self } ++ args); + } + } + return false; + } + + /// Dispatch to a `void`-returning optional hook on the active + /// tab. No-op if the active tab doesn't declare the hook. + /// See `dispatchBool` for the `args` tuple convention and the + /// rationale for the `anytype` parameter. + fn dispatchVoid(self: *App, comptime hook_name: []const u8, args: anytype) 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); + if (!@hasDecl(Module.tab, hook_name)) return; + const fn_ptr = @field(Module.tab, hook_name); + const state_ptr = &@field(self.states, field.name); + @call(.auto, fn_ptr, .{ state_ptr, self } ++ args); + return; + } + } + } + + /// Broadcast to every tab that declares `hook_name` — not just + /// the active tab. Tabs that don't declare it are skipped. + /// Order is `tab_modules` declaration order; callers should not + /// rely on a particular order across tabs (each tab's hook is + /// expected to handle the change independently). + /// + /// Used for global context changes that every interested tab + /// needs to react to (e.g. `onSymbolChange` — every tab with + /// per-symbol cached state opts in to drop it). Distinct from + /// `dispatchVoid`, which only notifies the active tab. + /// + /// Only meaningful for void-returning hooks; bool-returning + /// hooks have ambiguous broadcast semantics ("all consumed"? + /// "any consumed"?) so the framework declines to define them. + /// See `dispatchBool` for the `args` tuple convention and the + /// rationale for `anytype`. + fn broadcast(self: *App, comptime hook_name: []const u8, args: anytype) void { + inline for (std.meta.fields(@TypeOf(tab_modules))) |field| { + const Module = @field(tab_modules, field.name); + if (@hasDecl(Module.tab, hook_name)) { + const fn_ptr = @field(Module.tab, hook_name); + const state_ptr = &@field(self.states, field.name); + @call(.auto, fn_ptr, .{ state_ptr, self } ++ args); + } + } + } + + /// Call a `bool`-returning App-level predicate hook on the + /// specified tab (not necessarily the active tab). Returns + /// `false` if the tab doesn't declare the hook. Distinct from + /// `dispatchBool` because the hook signature is `fn(*App)bool` + /// — App-only, no tab State arg. Used for predicates like + /// `isDisabled` that depend on App-level context (whether a + /// portfolio is loaded, etc.) rather than tab-private state. + fn appPredicate(self: *App, target: Tab, comptime hook_name: []const u8) bool { + inline for (std.meta.fields(@TypeOf(tab_modules))) |field| { + if (std.mem.eql(u8, field.name, @tagName(target))) { + const Module = @field(tab_modules, field.name); + if (!@hasDecl(Module.tab, hook_name)) return false; + return @field(Module.tab, hook_name)(self); + } + } + return false; + } + /// Outcome of a single keypress in an input-mode buffer (symbol /// input, date input, etc.). Returned by `handleInputBuffer` so /// the per-mode caller only needs to wire up the `committed` @@ -1269,9 +1327,9 @@ pub const App = struct { /// Load accounts.srf if not already loaded. Derives path from portfolio_path. pub fn ensureAccountMap(self: *App) void { - if (self.account_map != null) return; + if (self.portfolio.account_map != null) return; const ppath = self.portfolio_path orelse return; - self.account_map = self.svc.loadAccountMap(ppath); + self.portfolio.account_map = self.svc.loadAccountMap(ppath); } /// Set or clear the account filter. Owns the string via allocator. @@ -1282,7 +1340,7 @@ pub const App = struct { if (name) |n| { self.account_filter = self.allocator.dupe(u8, n) catch null; - if (self.portfolio) |pf| { + if (self.portfolio.file) |pf| { self.filtered_positions = pf.positionsForAccount(self.today, self.allocator, n) catch null; } } else { @@ -1397,7 +1455,7 @@ pub const App = struct { self.toggleExpand(); return ctx.consumeAndRedraw(); } else if (self.active_tab == .options) { - self.toggleOptionsExpand(); + options_tab.tab.handleAction(&self.states.options, self, options_tab.Action.expand_collapse); return ctx.consumeAndRedraw(); } else if (self.active_tab == .history) { if (history_tab.toggleTierAtCursor(self)) { @@ -1429,15 +1487,14 @@ pub const App = struct { .scroll_top => { self.scroll_offset = 0; if (self.active_tab == .portfolio) self.cursor = 0; - if (self.active_tab == .options) self.options_cursor = 0; + self.dispatchVoid("onScroll", .{tab_framework.ScrollEdge.top}); return ctx.consumeAndRedraw(); }, .scroll_bottom => { self.scroll_offset = std.math.maxInt(usize) / 2; // clamped during draw...divide by 2 to avoid overflow if arithmetic is done if (self.active_tab == .portfolio and self.portfolio_rows.items.len > 0) self.cursor = self.portfolio_rows.items.len - 1; - if (self.active_tab == .options and self.options_rows.items.len > 0) - self.options_cursor = self.options_rows.items.len - 1; + self.dispatchVoid("onScroll", .{tab_framework.ScrollEdge.bottom}); return ctx.consumeAndRedraw(); }, .help => { @@ -1451,40 +1508,36 @@ pub const App = struct { }, .collapse_all_calls => { if (self.active_tab == .options) { - self.toggleAllCallsPuts(true); + options_tab.tab.handleAction(&self.states.options, self, options_tab.Action.collapse_all_calls); return ctx.consumeAndRedraw(); } }, .collapse_all_puts => { if (self.active_tab == .options) { - self.toggleAllCallsPuts(false); + options_tab.tab.handleAction(&self.states.options, self, options_tab.Action.collapse_all_puts); 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); + // Route through tab.handleAction so the dispatch + // table is consistent with the framework. Each + // filter_N maps directly to options_tab.Action.filter_N. + const tab_action: options_tab.Action = @enumFromInt(@intFromEnum(options_tab.Action.filter_1) + n - 1); + options_tab.tab.handleAction(&self.states.options, self, tab_action); 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()); + quote_tab.tab.handleAction(&self.states.quote, self, quote_tab.Action.chart_timeframe_next); 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()); + quote_tab.tab.handleAction(&self.states.quote, self, quote_tab.Action.chart_timeframe_prev); return ctx.consumeAndRedraw(); } }, @@ -1561,7 +1614,7 @@ pub const App = struct { } }, .account_filter => { - if (self.active_tab == .portfolio and self.portfolio != null) { + if (self.active_tab == .portfolio and self.portfolio.file != null) { self.mode = .account_picker; // Position cursor on the currently-active filter (or 0 for "All") self.account_picker_cursor = 0; @@ -1650,32 +1703,42 @@ pub const App = struct { } /// Move cursor/scroll. Positive = down, negative = up. - /// For portfolio and options tabs, moves the row cursor by 1 with + /// For tabs with a row cursor, moves the cursor by 1 with /// debounce to absorb duplicate events from mouse wheel ticks. - /// For other tabs, adjusts scroll_offset by |n|. + /// For other tabs (or cursor-bearing tabs with empty rows), + /// adjusts scroll_offset by |n|. fn moveBy(self: *App, n: isize) void { - if (self.active_tab == .portfolio or self.active_tab == .options) { + // Unmigrated cursor-bearing tabs (portfolio + history). + // Their cursor state still lives on App; once migrated, + // both will move into onCursorMove hooks like options. + if (self.active_tab == .portfolio) { if (self.shouldDebounceWheel()) return; - 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 (self.active_tab == .history and self.history_compare_view == null and self.history_table_row_count > 0) { - // Cursor navigation in the recent-snapshots table. Disabled - // during compare view mode (Esc/`c` returns first). + stepCursor(&self.cursor, self.portfolio_rows.items.len, n); + self.ensureCursorVisible(); + return; + } + if (self.active_tab == .history and self.history_compare_view == null and self.history_table_row_count > 0) { if (self.shouldDebounceWheel()) return; stepCursor(&self.history_cursor, self.history_table_row_count, n); self.ensureHistoryCursorVisible(); + return; + } + // Migrated cursor-bearing tabs (currently: options). The + // hook returns false when it has no rows, so we fall + // through to scroll. Debounce applies to the cursor-move + // path only — preserving legacy behavior where wheel + // events on non-cursor views scroll without debounce. + if (self.activeTabHas("onCursorMove")) { + if (self.shouldDebounceWheel()) return; + if (self.dispatchBool("onCursorMove", .{n})) return; + // Hook declined (empty rows) — fall through to scroll. + } + // Non-cursor tabs: scroll the viewport directly. + if (n > 0) { + self.scroll_offset += @intCast(n); } 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; - } + const abs: usize = @intCast(-n); + if (self.scroll_offset > abs) self.scroll_offset -= abs else self.scroll_offset = 0; } } @@ -1743,134 +1806,6 @@ pub const App = struct { } } - 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"); - } - } - - pub 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]); @@ -1882,36 +1817,16 @@ pub const App = struct { } fn resetSymbolData(self: *App) void { - self.perf_loaded = false; - self.options_loaded = false; - self.etf_loaded = false; - self.options_cursor = 0; - self.options_expanded = @splat(false); - self.options_calls_collapsed = @splat(false); - self.options_puts_collapsed = @splat(false); - self.options_rows.clearRetainingCapacity(); - self.candle_timestamp = 0; - self.options_timestamp = 0; - self.quote = null; - self.quote_timestamp = 0; - self.freeCandles(); - self.freeDividends(); - 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; // Tab-private symbol-bound state is dropped via each tab's // onSymbolChange hook (where defined). Distinct from // `tab.deinit` (App teardown) and `tab.reload` (drops AND // re-fetches) — these hooks just drop the cache; the next // `activate` will re-fetch lazily. - earnings_tab.tab.onSymbolChange(&self.states.earnings, self); + self.broadcast("onSymbolChange", .{}); + + // App-level shared per-symbol cache. + self.symbol_data.clear(self.allocator); self.scroll_offset = 0; - self.chart.dirty = true; - self.chart.freeCache(self.allocator); // Invalidate indicator cache } fn refreshCurrentTab(self: *App) void { @@ -1933,29 +1848,26 @@ pub const App = struct { } switch (self.active_tab) { .portfolio => { - self.portfolio_loaded = false; + self.portfolio.loaded = false; self.freePortfolioSummary(); }, .quote, .performance => { - self.perf_loaded = false; - self.freeCandles(); - self.freeDividends(); - self.chart.dirty = true; - self.chart.freeCache(self.allocator); // Invalidate indicator cache + self.states.performance.loaded = false; + if (self.symbol_data.candles) |c| self.allocator.free(c); + self.symbol_data.candles = null; + if (self.symbol_data.dividends) |d| zfin.Dividend.freeSlice(self.allocator, d); + self.symbol_data.dividends = null; + self.states.quote.chart.dirty = true; + self.states.quote.chart.freeCache(self.allocator); // Invalidate indicator cache }, .earnings => { earnings_tab.tab.reload(&self.states.earnings, self) catch {}; }, .options => { - self.options_loaded = false; - self.freeOptions(); + options_tab.tab.reload(&self.states.options, self) catch {}; }, .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; + analysis_tab.tab.reload(&self.states.analysis, self) catch {}; }, .history => { self.history_loaded = false; @@ -1973,11 +1885,11 @@ pub const App = struct { .quote, .performance => { if (self.symbol.len > 0) { if (self.svc.getQuote(self.symbol)) |q| { - self.quote = q; + self.states.quote.live = q; // wall-clock required: records the exact moment // this quote was served so the "refreshed Xs ago" // display is honest about freshness. - self.quote_timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds(); + self.states.quote.timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds(); } else |_| {} } }, @@ -1986,14 +1898,13 @@ pub const App = struct { } fn loadTabData(self: *App) void { - self.data_error = null; switch (self.active_tab) { .portfolio => { - if (!self.portfolio_loaded) self.loadPortfolioData(); + if (!self.portfolio.loaded) self.loadPortfolioData(); }, .quote, .performance => { if (self.symbol.len == 0) return; - if (!self.perf_loaded) perf_tab.loadData(self); + perf_tab.tab.activate(&self.states.performance, self) catch {}; }, .earnings => { if (self.symbol.len == 0) return; @@ -2001,11 +1912,10 @@ pub const App = struct { }, .options => { if (self.symbol.len == 0) return; - if (!self.options_loaded) options_tab.loadData(self); + options_tab.tab.activate(&self.states.options, self) catch {}; }, .analysis => { - if (self.analysis_disabled) return; - if (!self.analysis_loaded) self.loadAnalysisData(); + analysis_tab.tab.activate(&self.states.analysis, self) catch {}; }, .history => { if (self.history_disabled) return; @@ -2041,44 +1951,9 @@ pub const App = struct { 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 freeOptions(self: *App) void { - if (self.options_data) |chains| { - zfin.OptionsChain.freeSlice(self.allocator, 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; + if (self.portfolio.summary) |*s| s.deinit(self.allocator); + self.portfolio.summary = null; } pub fn freePreparedSections(self: *App) void { @@ -2089,28 +1964,22 @@ pub const App = struct { } fn deinitData(self: *App) void { - self.freeCandles(); - self.freeDividends(); - self.freeOptions(); - self.freeEtfProfile(); - self.freePortfolioSummary(); + self.symbol_data.deinit(self.allocator); earnings_tab.tab.deinit(&self.states.earnings, self); + options_tab.tab.deinit(&self.states.options, self); self.freePreparedSections(); self.portfolio_rows.deinit(self.allocator); - self.options_rows.deinit(self.allocator); self.account_list.deinit(self.allocator); self.account_numbers.deinit(self.allocator); self.account_shortcut_keys.deinit(self.allocator); self.account_search_matches.deinit(self.allocator); if (self.account_filter) |af| self.allocator.free(af); if (self.filtered_positions) |fp| self.allocator.free(fp); - 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(); + analysis_tab.tab.deinit(&self.states.analysis, self); + self.portfolio.deinit(self.allocator); history_tab.freeLoaded(self); projections_tab.freeLoaded(self); - self.chart.freeCache(self.allocator); // Free cached indicators + quote_tab.tab.deinit(&self.states.quote, self); } fn reloadPortfolioFile(self: *App) void { @@ -2185,9 +2054,15 @@ pub const App = struct { return .{ .size = .{ .width = width, .height = 1 }, .widget = self.widget(), .buffer = buf, .children = &.{} }; } + /// Whether the given tab should be treated as disabled in + /// the current App context. Migrated tabs are consulted via + /// their framework-contract `isDisabled` hook; unmigrated tabs + /// (history, projections) still use bespoke `_disabled` flags + /// set during App init. The unmigrated branches go away when + /// those tabs adopt the framework — the `_disabled` fields + /// will be deleted alongside the inline checks here. fn isTabDisabled(self: *App, t: Tab) bool { - return (t == .earnings and earnings_tab.tab.isDisabled(self)) or - (t == .analysis and self.analysis_disabled) or + return self.appPredicate(t, "isDisabled") or (t == .history and self.history_disabled) or (t == .projections and self.projections_disabled); } @@ -2386,10 +2261,6 @@ pub const App = struct { // ── 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); } @@ -2726,7 +2597,7 @@ pub fn run( .portfolio_path = portfolio_path, .symbol = symbol, .has_explicit_symbol = has_explicit_symbol, - .chart = .{ .config = chart_config }, + .chart_config = chart_config, .history_expanded_buckets = std.AutoHashMap(history_tab.BucketKey, void).init(allocator), }; defer app_inst.history_expanded_buckets.deinit(); @@ -2736,7 +2607,7 @@ pub fn run( if (file_data) |d| { defer allocator.free(d); if (zfin.cache.deserializePortfolio(allocator, d)) |pf| { - app_inst.portfolio = pf; + app_inst.portfolio.file = pf; } else |_| {} } } @@ -2761,9 +2632,11 @@ pub fn run( app_inst.active_tab = .quote; } - // Disable analysis tab when no portfolio is loaded (analysis requires portfolio) - if (app_inst.portfolio == null) { - app_inst.analysis_disabled = true; + // Disable projections tab when no portfolio is loaded. + // Analysis derives the same condition via its `isDisabled` + // method (no field write needed) — the predicate is computed + // from `app.portfolio.file == null` directly. + if (app_inst.portfolio.file == null) { app_inst.projections_disabled = true; } // History tab also requires a portfolio path to locate the @@ -2774,7 +2647,7 @@ pub fn run( // 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| { + if (app_inst.portfolio.file) |pf| { const syms = pf.stockSymbols(allocator) catch null; defer if (syms) |s| allocator.free(s); @@ -2822,12 +2695,11 @@ pub fn run( // without requiring the user to visit the portfolio tab // first. Cheap (pure compute + cache reads) once prices are // already in hand. - if (app_inst.portfolio != null) { + if (app_inst.portfolio.file != null) { portfolio_tab.loadPortfolioData(app_inst); } } - defer if (app_inst.portfolio) |*pf| pf.deinit(); defer freeWatchlist(allocator, app_inst.watchlist); defer app_inst.deinitData(); @@ -2842,9 +2714,9 @@ pub fn run( defer app_inst.vx_app = null; defer { // Free any chart image before vaxis is torn down - if (app_inst.chart.image_id) |id| { + if (app_inst.states.quote.chart.image_id) |id| { vx_app.vx.freeImage(vx_app.tty.writer(), id); - app_inst.chart.image_id = null; + app_inst.states.quote.chart.image_id = null; } if (app_inst.projections_image_id) |id| { vx_app.vx.freeImage(vx_app.tty.writer(), id); diff --git a/src/tui/analysis_tab.zig b/src/tui/analysis_tab.zig index d268ea1..35e4fcf 100644 --- a/src/tui/analysis_tab.zig +++ b/src/tui/analysis_tab.zig @@ -5,21 +5,109 @@ const fmt = @import("../format.zig"); const Money = @import("../Money.zig"); const theme = @import("theme.zig"); const tui = @import("../tui.zig"); +const framework = @import("tab_framework.zig"); const App = tui.App; const StyledLine = tui.StyledLine; +// ── Tab-local action enum ───────────────────────────────────── +// +// Analysis has no tab-local keybinds today — it's a read-only +// breakdown view. Refresh is global. Empty enum is the explicit +// placeholder. + +pub const Action = enum {}; + +// ── Tab-private state ───────────────────────────────────────── + +pub const State = struct { + /// Whether `init`/`activate` has populated `result`. Internal + /// short-circuit for `activate`; App never reads this. + loaded: bool = false, + /// Computed analysis output. Owned by State; freed in + /// `deinit` and `reload`. + result: ?zfin.analysis.AnalysisResult = null, + /// Per-portfolio classification metadata (`metadata.srf`). + /// Used only by analysis today; lives here because no other + /// tab consumes it. Loaded lazily on first activation; freed + /// in `deinit`. + classification_map: ?zfin.classification.ClassificationMap = null, +}; + +// ── Tab framework contract ──────────────────────────────────── + +pub const tab = struct { + pub const ActionT = Action; + pub const StateT = State; + + pub const default_bindings: []const framework.TabBinding(Action) = &.{}; + pub const action_labels = std.enums.EnumArray(Action, []const u8).initFill(""); + pub const status_hints: []const Action = &.{}; + + pub fn init(state: *State, app: *App) !void { + _ = app; + state.* = .{}; + } + + pub fn deinit(state: *State, app: *App) void { + if (state.result) |*ar| ar.deinit(app.allocator); + if (state.classification_map) |*cm| cm.deinit(); + state.* = .{}; + } + + pub fn activate(state: *State, app: *App) !void { + if (tab.isDisabled(app)) return; + if (state.loaded) return; + loadData(state, app); + } + + pub const deactivate = framework.noopDeactivate(State); + + /// Force re-fetch on user request. Frees the analysis result + /// AND the shared `account_map` on App (analysis's refresh + /// also re-reads accounts.srf). The classification_map persists + /// — it's per-portfolio, not per-symbol or per-refresh. + pub fn reload(state: *State, app: *App) !void { + if (state.result) |*ar| ar.deinit(app.allocator); + state.result = null; + state.loaded = false; + // Refresh-analysis intentionally drops the shared account + // map so the next load re-reads `accounts.srf` from disk + // (the user may have edited it). + if (app.portfolio.account_map) |*am| am.deinit(); + app.portfolio.account_map = null; + loadData(state, app); + } + + pub const tick = framework.noopTick(State); + + pub fn handleAction(state: *State, app: *App, action: Action) void { + _ = state; + _ = app; + switch (action) {} + } + + /// Analysis requires a loaded portfolio file (the breakdown + /// is computed from `app.portfolio.summary.allocations`). + /// Derived directly from `app.portfolio.file` rather than + /// stored as a field, so it can't go stale and App never has + /// to reach across into the tab's State to set it. + pub fn isDisabled(app: *App) bool { + return app.portfolio.file == null; + } +}; + // ── Data loading ────────────────────────────────────────────── -pub fn loadData(app: *App) void { - app.analysis_loaded = true; +fn loadData(state: *State, app: *App) void { + state.loaded = true; // Ensure portfolio is loaded first - if (!app.portfolio_loaded) app.loadPortfolioData(); - const pf = app.portfolio orelse return; - const summary = app.portfolio_summary orelse return; + if (!app.portfolio.loaded) app.loadPortfolioData(); + const pf = app.portfolio.file orelse return; + const summary = app.portfolio.summary orelse return; // Load classification metadata file - if (app.classification_map == null) { + if (state.classification_map == null) { // Look for metadata.srf next to the portfolio file if (app.portfolio_path) |ppath| { // Derive metadata path: same directory as portfolio, named "metadata.srf" @@ -33,7 +121,7 @@ pub fn loadData(app: *App) void { }; defer app.allocator.free(file_data); - app.classification_map = zfin.classification.parseClassificationFile(app.allocator, file_data) catch { + state.classification_map = zfin.classification.parseClassificationFile(app.allocator, file_data) catch { app.setStatus("Error parsing metadata.srf"); return; }; @@ -43,25 +131,25 @@ pub fn loadData(app: *App) void { // Load account tax type metadata file (optional) app.ensureAccountMap(); - loadDataFinish(app, pf, summary); + loadDataFinish(state, app, pf, summary); } -fn loadDataFinish(app: *App, pf: zfin.Portfolio, summary: zfin.valuation.PortfolioSummary) void { - const cm = app.classification_map orelse { +fn loadDataFinish(state: *State, app: *App, pf: zfin.Portfolio, summary: zfin.valuation.PortfolioSummary) void { + const cm = state.classification_map orelse { app.setStatus("No classification data. Run: zfin enrich > metadata.srf"); return; }; // Free previous result - if (app.analysis_result) |*ar| ar.deinit(app.allocator); + if (state.result) |*ar| ar.deinit(app.allocator); - app.analysis_result = zfin.analysis.analyzePortfolio( + state.result = zfin.analysis.analyzePortfolio( app.allocator, summary.allocations, cm, pf, summary.total_value, - app.account_map, + app.portfolio.account_map, app.today, // live mode in TUI → resolves to app.today ) catch { app.setStatus("Error computing analysis"); @@ -72,15 +160,16 @@ fn loadDataFinish(app: *App, pf: zfin.Portfolio, summary: zfin.valuation.Portfol // ── Rendering ───────────────────────────────────────────────── pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine { + const state = &app.states.analysis; // Compute equity/fixed split from classification + portfolio var stock_pct: f64 = 0; var bond_pct: f64 = 0; var total_value: f64 = 0; - if (app.portfolio_summary) |summary| { + if (app.portfolio.summary) |summary| { total_value = summary.total_value; - if (app.portfolio) |pf| { + if (app.portfolio.file) |pf| { const benchmark = @import("../analytics/benchmark.zig"); - const cm_entries = if (app.classification_map) |cm| cm.entries else &.{}; + const cm_entries = if (state.classification_map) |cm| cm.entries else &.{}; const split = benchmark.deriveAllocationSplit( summary.allocations, cm_entries, @@ -92,7 +181,7 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine bond_pct = split.bond_pct; } } - return renderAnalysisLines(arena, app.theme, app.analysis_result, stock_pct, bond_pct, total_value); + return renderAnalysisLines(arena, app.theme, state.result, stock_pct, bond_pct, total_value); } /// Render analysis tab content. Pure function — no App dependency. @@ -278,3 +367,14 @@ test "renderAnalysisLines no data" { try testing.expectEqual(@as(usize, 5), lines.len); try testing.expect(std.mem.indexOf(u8, lines[3].text, "No analysis data") != null); } + +test "tab.init produces zero-defaulted state" { + var state: State = undefined; + var dummy_app: tui.App = undefined; // intentionally undefined: init + // for analysis doesn't touch app fields. + + try tab.init(&state, &dummy_app); + try testing.expectEqual(false, state.loaded); + try testing.expect(state.result == null); + try testing.expect(state.classification_map == null); +} diff --git a/src/tui/history_tab.zig b/src/tui/history_tab.zig index 48a4bce..f598a7e 100644 --- a/src/tui/history_tab.zig +++ b/src/tui/history_tab.zig @@ -21,7 +21,7 @@ //! - Esc — exit compare view if active, else clear pending selections. //! //! The "today (live)" pseudo-row is conditional: it appears as the -//! newest row when `app.portfolio_summary` and `app.prefetched_prices` +//! newest row when `app.portfolio.summary` and `app.prefetched_prices` //! are populated. When present, `history_cursor = 0` points at it. //! //! Consumes `src/analytics/timeline.zig` (pure compute) and @@ -335,7 +335,7 @@ fn buildCompareFromSelections(app: *App, sel_a: usize, sel_b: usize) !void { if (older.is_live) { var map: compare_view.HoldingMap = .init(app.allocator); errdefer map.deinit(); - try aggregateFromSummary(app.portfolio_summary.?, &map); + try aggregateFromSummary(app.portfolio.summary.?, &map); resources.then_live_map = map; then_map_ptr = &resources.then_live_map.?; then_liquid = liveLiquid(app); @@ -353,7 +353,7 @@ fn buildCompareFromSelections(app: *App, sel_a: usize, sel_b: usize) !void { if (newer.is_live) { var map: compare_view.HoldingMap = .init(app.allocator); errdefer map.deinit(); - try aggregateFromSummary(app.portfolio_summary.?, &map); + try aggregateFromSummary(app.portfolio.summary.?, &map); resources.now_live_map = map; now_map_ptr = &resources.now_live_map.?; now_liquid = liveLiquid(app); @@ -410,7 +410,7 @@ fn buildCompareFromSelections(app: *App, sel_a: usize, sel_b: usize) !void { } fn liveLiquid(app: *const App) f64 { - if (app.portfolio_summary) |s| return s.total_value; + if (app.portfolio.summary) |s| return s.total_value; return 0; } @@ -753,11 +753,11 @@ fn priorPointBefore(series: []const timeline.TimelinePoint, target: zfin.Date) ? /// Build the live row for the cascading path. Uses the newest /// bucket (typically a daily bucket) as the comparison anchor. fn buildLiveRowFromCascading(app: *const App, buckets: []const timeline.TierBucket) ?TableRow { - if (app.portfolio == null) return null; - const summary = app.portfolio_summary orelse return null; + if (app.portfolio.file == null) return null; + const summary = app.portfolio.summary orelse return null; const liquid = summary.total_value; - const illiquid = app.portfolio.?.totalIlliquid(app.today); + const illiquid = app.portfolio.file.?.totalIlliquid(app.today); const net_worth = liquid + illiquid; var d_liquid: ?f64 = null; @@ -790,11 +790,11 @@ fn buildLiveRowFromCascading(app: *const App, buckets: []const timeline.TierBuck /// Deltas are computed against the newest snapshot in `deltas` (which /// is index `deltas.len - 1` — deltas is oldest-first). fn buildLiveRow(app: *const App, deltas: []const timeline.RowDelta) ?TableRow { - if (app.portfolio == null) return null; - const summary = app.portfolio_summary orelse return null; + if (app.portfolio.file == null) return null; + const summary = app.portfolio.summary orelse return null; const liquid = summary.total_value; - const illiquid = app.portfolio.?.totalIlliquid(app.today); + const illiquid = app.portfolio.file.?.totalIlliquid(app.today); const net_worth = liquid + illiquid; // Deltas vs. the most recent snapshot. diff --git a/src/tui/options_tab.zig b/src/tui/options_tab.zig index da7bd66..0a6b69f 100644 --- a/src/tui/options_tab.zig +++ b/src/tui/options_tab.zig @@ -5,15 +5,274 @@ const fmt = @import("../format.zig"); const Money = @import("../Money.zig"); const theme = @import("theme.zig"); const tui = @import("../tui.zig"); +const framework = @import("tab_framework.zig"); const App = tui.App; const StyledLine = tui.StyledLine; +const OptionsRow = tui.OptionsRow; + +// ── Tab-local action enum ───────────────────────────────────── +// +// Options tab keybinds: +// - Enter : expand/collapse the row at the cursor. +// - `c` / `p` : collapse-or-expand all calls / puts. +// - Ctrl-1 .. Ctrl-9 : set NTM filter to N strikes around ATM. + +pub const Action = enum { + /// Toggle the row at the cursor: expand/collapse an expiration, + /// or collapse/expand the calls/puts subsection at the cursor. + /// Mouse single-click on a row dispatches the same action. + expand_collapse, + collapse_all_calls, + collapse_all_puts, + filter_1, + filter_2, + filter_3, + filter_4, + filter_5, + filter_6, + filter_7, + filter_8, + filter_9, +}; + +// ── Tab-private state ───────────────────────────────────────── + +pub const State = struct { + /// Loaded options chains for the active symbol. Owned by State; + /// freed via `deinit` and `reload`. + chains: ?[]zfin.OptionsChain = null, + /// Whether `activate` has populated `chains` (or set `disabled`). + /// The chains slice is null until the first successful fetch + /// even if `loaded == true` (failed fetches still mark loaded). + loaded: bool = false, + /// Timestamp of the chains fetch — drives the "data Xs ago" + /// header readout. + timestamp: i64 = 0, + /// Cursor position in the flattened options rows view. + cursor: usize = 0, + /// Per-expiration: is the expiration expanded (showing calls + /// + puts subsections)? + expanded: [64]bool = @splat(false), + /// Per-expiration: when expanded, are the calls collapsed? + calls_collapsed: [64]bool = @splat(false), + /// Per-expiration: when expanded, are the puts collapsed? + puts_collapsed: [64]bool = @splat(false), + /// Number of strikes around ATM to show. Adjusted with Ctrl-1..9. + near_the_money: usize = 8, + /// Flattened display rows (expirations + headers + contracts). + /// Rebuilt by `rebuildRows` whenever `expanded` or + /// `near_the_money` changes. + rows: std.ArrayList(OptionsRow) = .empty, + /// Number of styled lines emitted before the first data row. + /// Used by mouse-click handling to map screen rows to data rows. + header_lines: usize = 0, +}; + +// ── Tab framework contract ──────────────────────────────────── + +pub const tab = struct { + pub const ActionT = Action; + pub const StateT = State; + + pub const default_bindings: []const framework.TabBinding(Action) = &.{ + .{ .action = .expand_collapse, .key = .{ .codepoint = vaxis.Key.enter } }, + .{ .action = .collapse_all_calls, .key = .{ .codepoint = 'c' } }, + .{ .action = .collapse_all_puts, .key = .{ .codepoint = 'p' } }, + .{ .action = .filter_1, .key = .{ .codepoint = '1', .mods = .{ .ctrl = true } } }, + .{ .action = .filter_2, .key = .{ .codepoint = '2', .mods = .{ .ctrl = true } } }, + .{ .action = .filter_3, .key = .{ .codepoint = '3', .mods = .{ .ctrl = true } } }, + .{ .action = .filter_4, .key = .{ .codepoint = '4', .mods = .{ .ctrl = true } } }, + .{ .action = .filter_5, .key = .{ .codepoint = '5', .mods = .{ .ctrl = true } } }, + .{ .action = .filter_6, .key = .{ .codepoint = '6', .mods = .{ .ctrl = true } } }, + .{ .action = .filter_7, .key = .{ .codepoint = '7', .mods = .{ .ctrl = true } } }, + .{ .action = .filter_8, .key = .{ .codepoint = '8', .mods = .{ .ctrl = true } } }, + .{ .action = .filter_9, .key = .{ .codepoint = '9', .mods = .{ .ctrl = true } } }, + }; + + pub const action_labels = std.enums.EnumArray(Action, []const u8).init(.{ + .expand_collapse = "Expand/collapse row", + .collapse_all_calls = "Toggle all calls", + .collapse_all_puts = "Toggle all puts", + .filter_1 = "Filter +/- 1 NTM", + .filter_2 = "Filter +/- 2 NTM", + .filter_3 = "Filter +/- 3 NTM", + .filter_4 = "Filter +/- 4 NTM", + .filter_5 = "Filter +/- 5 NTM", + .filter_6 = "Filter +/- 6 NTM", + .filter_7 = "Filter +/- 7 NTM", + .filter_8 = "Filter +/- 8 NTM", + .filter_9 = "Filter +/- 9 NTM", + }); + + pub const status_hints: []const Action = &.{ + .collapse_all_calls, + .collapse_all_puts, + }; + + pub fn init(state: *State, app: *App) !void { + _ = app; + state.* = .{}; + } + + pub fn deinit(state: *State, app: *App) void { + if (state.chains) |chains| { + zfin.OptionsChain.freeSlice(app.allocator, chains); + } + state.rows.deinit(app.allocator); + state.* = .{}; + } + + pub fn activate(state: *State, app: *App) !void { + if (app.symbol.len == 0) return; + if (state.loaded) return; + loadData(state, app); + } + + pub const deactivate = framework.noopDeactivate(State); + + pub fn reload(state: *State, app: *App) !void { + // Drop chains first so loadData starts clean. + if (state.chains) |chains| { + zfin.OptionsChain.freeSlice(app.allocator, chains); + } + // Preserve user UX choices across refresh: cursor, expanded + // sections, near-the-money filter. Just clear the data. + state.chains = null; + state.loaded = false; + state.timestamp = 0; + loadData(state, app); + } + + pub const tick = framework.noopTick(State); + + pub fn handleAction(state: *State, app: *App, action: Action) void { + switch (action) { + .collapse_all_calls => toggleAllCallsPuts(state, app, true), + .collapse_all_puts => toggleAllCallsPuts(state, app, false), + .expand_collapse => toggleExpandAtCursor(state, app), + .filter_1, .filter_2, .filter_3, .filter_4, .filter_5, .filter_6, .filter_7, .filter_8, .filter_9 => { + const n = @intFromEnum(action) - @intFromEnum(Action.filter_1) + 1; + state.near_the_money = n; + rebuildRows(state, app); + var status_buf: [32]u8 = undefined; + const msg = std.fmt.bufPrint(&status_buf, "+/- {d} NTM strikes", .{n}) catch "Filter changed"; + app.setStatus(msg); + }, + } + } + + /// Mouse handling: a single-click on a data row moves the + /// cursor to that row and toggles expand/collapse — same effect + /// as pressing Enter on the row. Returns `true` if the click + /// landed on a data row (consumed); `false` otherwise (unhandled, + /// e.g. clicks above the table or on blank lines). + pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool { + if (mouse.button != .left) return false; + if (mouse.type != .press) return false; + if (mouse.row == 0) return false; // tab bar — App handles + const content_row = @as(usize, @intCast(mouse.row)) + app.scroll_offset; + if (content_row < state.header_lines) return false; + if (state.rows.items.len == 0) return false; + + // Walk 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 - state.header_lines; + var current_line: usize = 0; + for (state.rows.items, 0..) |orow, oi| { + if (orow.kind == .puts_header) current_line += 1; // extra blank + if (current_line == target_line) { + state.cursor = oi; + toggleExpandAtCursor(state, app); + return true; + } + current_line += 1; + } + return false; + } + + pub fn isDisabled(app: *App) bool { + _ = app; + return false; + } + + /// Symbol-change reset. Drops chains + display rows, clears + /// cursor + per-expiration collapse flags. Preserves + /// `near_the_money` because that's a persistent UX choice + /// (not symbol-bound). + pub fn onSymbolChange(state: *State, app: *App) void { + if (state.chains) |chains| { + zfin.OptionsChain.freeSlice(app.allocator, chains); + } + state.chains = null; + state.loaded = false; + state.timestamp = 0; + state.cursor = 0; + state.expanded = @splat(false); + state.calls_collapsed = @splat(false); + state.puts_collapsed = @splat(false); + state.rows.clearRetainingCapacity(); + state.header_lines = 0; + // near_the_money preserved. + } + + /// Sync the row cursor to the new scroll extreme. The framework + /// updates `app.scroll_offset` itself; this hook just keeps the + /// tab's own cursor consistent with what's now visible. + pub fn onScroll(state: *State, app: *App, where: framework.ScrollEdge) void { + _ = app; + switch (where) { + .top => state.cursor = 0, + .bottom => { + if (state.rows.items.len > 0) { + state.cursor = state.rows.items.len - 1; + } + }, + } + } + + /// Step the row cursor by one row in `delta`'s direction. The + /// magnitude of `delta` is ignored — keys and wheel events + /// both move by a single row (matching legacy behavior). Returns + /// `false` when there are no rows to navigate so the framework + /// falls through to scrolling the viewport instead. + pub fn onCursorMove(state: *State, app: *App, delta: isize) bool { + if (state.rows.items.len == 0) return false; + stepCursor(&state.cursor, state.rows.items.len, delta); + ensureCursorVisible(state, &app.scroll_offset, app.visible_height); + return true; + } +}; + +// ── Cursor movement / visibility (private; called from onCursorMove) ── + +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(state: *const State, scroll_offset: *usize, visible_height: usize) void { + const cursor_row = state.cursor + state.header_lines; + if (cursor_row < scroll_offset.*) { + scroll_offset.* = cursor_row; + } + if (cursor_row >= scroll_offset.* + visible_height) { + scroll_offset.* = cursor_row - visible_height + 1; + } +} // ── Data loading ────────────────────────────────────────────── -pub fn loadData(app: *App) void { - app.options_loaded = true; - app.freeOptions(); +fn loadData(state: *State, app: *App) void { + state.loaded = true; + if (state.chains) |chains| { + zfin.OptionsChain.freeSlice(app.allocator, chains); + } + state.chains = null; const result = app.svc.getOptions(app.symbol) catch |err| { switch (err) { @@ -22,19 +281,155 @@ pub fn loadData(app: *App) void { } return; }; - app.options_data = result.data; - app.options_timestamp = result.timestamp; - app.options_cursor = 0; - app.options_expanded = @splat(false); - app.options_calls_collapsed = @splat(false); - app.options_puts_collapsed = @splat(false); - app.rebuildOptionsRows(); + state.chains = result.data; + state.timestamp = result.timestamp; + state.cursor = 0; + state.expanded = @splat(false); + state.calls_collapsed = @splat(false); + state.puts_collapsed = @splat(false); + rebuildRows(state, app); app.setStatus(if (result.source == .cached) "Cached (1hr TTL) | r/F5 to refresh" else "Fetched | r/F5 to refresh"); } +// ── Expand/collapse the row at the cursor ───────────────────── +// +// Called both from the keybind handler (`expand_collapse` action, +// bound to Enter) and from the mouse handler (single-click on a +// row). The behavior depends on the row kind: +// - `expiration` row: toggle the expiration's expanded flag +// (showing/hiding the calls + puts subsections). +// - `calls_header`/`puts_header`: toggle that subsection's +// collapsed flag. +// - call/put contract rows: no-op (clicking a contract is +// reserved for future per-contract actions). +// +// After any change, rebuilds the flat row list to reflect the new +// layout. No-op if the cursor is out of range or rows are empty. + +fn toggleExpandAtCursor(state: *State, app: *App) void { + if (state.rows.items.len == 0) return; + if (state.cursor >= state.rows.items.len) return; + const row = state.rows.items[state.cursor]; + switch (row.kind) { + .expiration => { + if (row.exp_idx < state.expanded.len) { + state.expanded[row.exp_idx] = !state.expanded[row.exp_idx]; + rebuildRows(state, app); + } + }, + .calls_header => { + if (row.exp_idx < state.calls_collapsed.len) { + state.calls_collapsed[row.exp_idx] = !state.calls_collapsed[row.exp_idx]; + rebuildRows(state, app); + } + }, + .puts_header => { + if (row.exp_idx < state.puts_collapsed.len) { + state.puts_collapsed[row.exp_idx] = !state.puts_collapsed[row.exp_idx]; + rebuildRows(state, app); + } + }, + // Clicking on a contract does nothing (yet). + else => {}, + } +} + +// ── Row rebuilding (after expansion/collapse changes) ──────── + +pub fn rebuildRows(state: *State, app: *App) void { + state.rows.clearRetainingCapacity(); + const chains = state.chains orelse return; + const atm_price = if (chains.len > 0) chains[0].underlying_price orelse 0 else @as(f64, 0); + + for (chains, 0..) |chain, ci| { + state.rows.append(app.allocator, .{ + .kind = .expiration, + .exp_idx = ci, + }) catch continue; + + if (ci < state.expanded.len and state.expanded[ci]) { + // Calls header (always shown when expanded, acts as toggle) + state.rows.append(app.allocator, .{ + .kind = .calls_header, + .exp_idx = ci, + }) catch continue; + + // Calls contracts (only if not collapsed) + if (!(ci < state.calls_collapsed.len and state.calls_collapsed[ci])) { + const filtered_calls = fmt.filterNearMoney(chain.calls, atm_price, state.near_the_money); + for (filtered_calls) |cc| { + state.rows.append(app.allocator, .{ + .kind = .call, + .exp_idx = ci, + .contract = cc, + }) catch continue; + } + } + + // Puts header + state.rows.append(app.allocator, .{ + .kind = .puts_header, + .exp_idx = ci, + }) catch continue; + + // Puts contracts (only if not collapsed) + if (!(ci < state.puts_collapsed.len and state.puts_collapsed[ci])) { + const filtered_puts = fmt.filterNearMoney(chain.puts, atm_price, state.near_the_money); + for (filtered_puts) |p| { + state.rows.append(app.allocator, .{ + .kind = .put, + .exp_idx = ci, + .contract = p, + }) catch continue; + } + } + } + } +} + +// ── All-calls / all-puts toggle ────────────────────────────── + +fn toggleAllCallsPuts(state: *State, app: *App, is_calls: bool) void { + const chains = state.chains 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 >= state.expanded.len) break; + if (!state.expanded[ci]) continue; // only count expanded expirations + if (is_calls) { + if (ci < state.calls_collapsed.len and !state.calls_collapsed[ci]) { + any_visible = true; + break; + } + } else { + if (ci < state.puts_collapsed.len and !state.puts_collapsed[ci]) { + any_visible = true; + break; + } + } + } + const new_state = any_visible; + for (chains, 0..) |_, ci| { + if (ci >= 64) break; + if (is_calls) { + state.calls_collapsed[ci] = new_state; + } else { + state.puts_collapsed[ci] = new_state; + } + } + rebuildRows(state, app); + if (is_calls) { + app.setStatus(if (new_state) "All calls collapsed" else "All calls expanded"); + } else { + app.setStatus(if (new_state) "All puts collapsed" else "All puts expanded"); + } +} + // ── Rendering ───────────────────────────────────────────────── pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine { + const state = &app.states.options; const th = app.theme; var lines: std.ArrayList(StyledLine) = .empty; @@ -45,7 +440,7 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine return lines.toOwnedSlice(arena); } - const chains = app.options_data orelse { + const chains = state.chains orelse { try lines.append(arena, .{ .text = " Loading options data...", .style = th.mutedStyle() }); return lines.toOwnedSlice(arena); }; @@ -60,7 +455,7 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine // readout. Captured here rather than on `app` so it refreshes every // time this tab renders. const now_s = std.Io.Timestamp.now(app.io, .real).toSeconds(); - const opt_ago = fmt.fmtTimeAgo(&opt_ago_buf, app.options_timestamp, now_s); + const opt_ago = fmt.fmtTimeAgo(&opt_ago_buf, state.timestamp, now_s); if (opt_ago.len > 0) { try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Options: {s} (data {s}, 15 min delay)", .{ app.symbol, opt_ago }), .style = th.headerStyle() }); } else { @@ -68,22 +463,22 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine } if (chains[0].underlying_price) |price| { - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Underlying: {f} {d} expiration(s) +/- {d} strikes NTM (Ctrl+1-9 to change)", .{ Money.from(price), chains.len, app.options_near_the_money }), .style = th.contentStyle() }); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Underlying: {f} {d} expiration(s) +/- {d} strikes NTM (Ctrl+1-9 to change)", .{ Money.from(price), chains.len, state.near_the_money }), .style = th.contentStyle() }); } try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); // Track header line count for mouse click mapping (after all non-data lines) - app.options_header_lines = lines.items.len; + state.header_lines = lines.items.len; // Flat list of options rows with inline expand/collapse - for (app.options_rows.items, 0..) |row, ri| { - const is_cursor = ri == app.options_cursor; + for (state.rows.items, 0..) |row, ri| { + const is_cursor = ri == state.cursor; switch (row.kind) { .expiration => { if (row.exp_idx < chains.len) { const chain = chains[row.exp_idx]; - const is_expanded = row.exp_idx < app.options_expanded.len and app.options_expanded[row.exp_idx]; + const is_expanded = row.exp_idx < state.expanded.len and state.expanded[row.exp_idx]; const is_monthly = fmt.isMonthlyExpiration(chain.expiration); const arrow: []const u8 = if (is_expanded) "v " else "> "; const text = try std.fmt.allocPrint(arena, " {s}{f} ({d} calls, {d} puts)", .{ @@ -97,7 +492,7 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine } }, .calls_header => { - const calls_collapsed = row.exp_idx < app.options_calls_collapsed.len and app.options_calls_collapsed[row.exp_idx]; + const calls_collapsed = row.exp_idx < state.calls_collapsed.len and state.calls_collapsed[row.exp_idx]; const arrow: []const u8 = if (calls_collapsed) " > " else " v "; const style = if (is_cursor) th.selectStyle() else th.headerStyle(); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "{s}{s:>10} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8} {s:>8} Calls", .{ @@ -105,7 +500,7 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine }), .style = style }); }, .puts_header => { - const puts_collapsed = row.exp_idx < app.options_puts_collapsed.len and app.options_puts_collapsed[row.exp_idx]; + const puts_collapsed = row.exp_idx < state.puts_collapsed.len and state.puts_collapsed[row.exp_idx]; const arrow: []const u8 = if (puts_collapsed) " > " else " v "; try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); const style = if (is_cursor) th.selectStyle() else th.headerStyle(); diff --git a/src/tui/perf_tab.zig b/src/tui/perf_tab.zig index 393c9b7..15f1510 100644 --- a/src/tui/perf_tab.zig +++ b/src/tui/perf_tab.zig @@ -5,23 +5,100 @@ const fmt = @import("../format.zig"); const Money = @import("../Money.zig"); const theme = @import("theme.zig"); const tui = @import("../tui.zig"); +const framework = @import("tab_framework.zig"); const App = tui.App; const StyledLine = tui.StyledLine; +// ── Tab-local action enum ───────────────────────────────────── +// +// Performance tab is read-only — no tab-local keybinds. Empty +// enum is the explicit placeholder per the framework contract. + +pub const Action = enum {}; + +// ── Tab-private state ───────────────────────────────────────── + +pub const State = struct { + /// Whether `activate` has populated `app.symbol_data` for the + /// current symbol. Distinct from `app.symbol_data.candles != + /// null` because candle data is shared with the quote tab and + /// might be populated by a different code path. + loaded: bool = false, +}; + +// ── Tab framework contract ──────────────────────────────────── + +pub const tab = struct { + pub const ActionT = Action; + pub const StateT = State; + + pub const default_bindings: []const framework.TabBinding(Action) = &.{}; + pub const action_labels = std.enums.EnumArray(Action, []const u8).initFill(""); + pub const status_hints: []const Action = &.{}; + + pub fn init(state: *State, app: *App) !void { + _ = app; + state.* = .{}; + } + + /// State teardown. Owned data lives on `app.symbol_data`, + /// which has its own deinit; nothing tab-local to free. + pub fn deinit(state: *State, app: *App) void { + _ = app; + state.* = .{}; + } + + pub fn activate(state: *State, app: *App) !void { + if (state.loaded) return; + if (app.symbol.len == 0) return; + loadData(state, app); + } + + pub const deactivate = framework.noopDeactivate(State); + + pub fn reload(state: *State, app: *App) !void { + state.loaded = false; + loadData(state, app); + } + + pub const tick = framework.noopTick(State); + + pub fn handleAction(state: *State, app: *App, action: Action) void { + _ = state; + _ = app; + switch (action) {} + } + + pub fn isDisabled(app: *App) bool { + _ = app; + return false; + } + + /// Symbol-change reset. Marks state as not-loaded so the next + /// `activate` re-runs `loadData`. The performance tab's per- + /// symbol fetched payload (candles, dividends, trailing returns) + /// lives on `app.symbol_data` and is dropped centrally by the + /// App when the symbol changes — this hook only owns the + /// tab-local "have I run for this symbol yet?" flag. + pub fn onSymbolChange(state: *State, app: *App) void { + _ = app; + state.loaded = false; + } +}; + // ── Data loading ────────────────────────────────────────────── -pub fn loadData(app: *App) void { - app.perf_loaded = true; - app.freeCandles(); - app.freeDividends(); - app.trailing_price = null; - app.trailing_total = null; - app.trailing_me_price = null; - app.trailing_me_total = null; - app.candle_count = 0; - app.candle_first_date = null; - app.candle_last_date = null; +fn loadData(state: *State, app: *App) void { + state.loaded = true; + if (app.symbol_data.candles) |c| app.allocator.free(c); + app.symbol_data.candles = null; + if (app.symbol_data.dividends) |d| zfin.Dividend.freeSlice(app.allocator, d); + app.symbol_data.dividends = null; + app.symbol_data.trailing_price = null; + app.symbol_data.trailing_total = null; + app.symbol_data.trailing_me_price = null; + app.symbol_data.trailing_me_total = null; const result = app.svc.getTrailingReturns(app.symbol) catch |err| { switch (err) { @@ -33,35 +110,35 @@ pub fn loadData(app: *App) void { } return; }; - app.candles = result.candles; - app.candle_timestamp = result.timestamp; + app.symbol_data.candles = result.candles; + app.symbol_data.candle_timestamp = result.timestamp; const c = result.candles; if (c.len == 0) { app.setStatus("No data available for symbol"); return; } - app.candle_count = c.len; - app.candle_first_date = c[0].date; - app.candle_last_date = c[c.len - 1].date; + // candle_count / candle_first_date / candle_last_date are derived + // from `candles` via methods on SymbolData — no field assignments + // needed here. - app.trailing_price = result.asof_price; - app.trailing_me_price = result.me_price; - app.trailing_total = result.asof_total; - app.trailing_me_total = result.me_total; + app.symbol_data.trailing_price = result.asof_price; + app.symbol_data.trailing_me_price = result.me_price; + app.symbol_data.trailing_total = result.asof_total; + app.symbol_data.trailing_me_total = result.me_total; if (result.dividends) |divs| { - app.dividends = divs; + app.symbol_data.dividends = divs; } - app.risk_metrics = zfin.risk.trailingRisk(c); + app.symbol_data.risk_metrics = zfin.risk.trailingRisk(c); // Try to load ETF profile (non-fatal, won't show for non-ETFs) - if (!app.etf_loaded) { - app.etf_loaded = true; + if (!app.symbol_data.etf_loaded) { + app.symbol_data.etf_loaded = true; if (app.svc.getEtfProfile(app.symbol)) |etf_result| { if (etf_result.data.isEtf()) { - app.etf_profile = etf_result.data; + app.symbol_data.etf_profile = etf_result.data; } } else |_| {} } @@ -82,41 +159,41 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine return lines.toOwnedSlice(arena); } - if (app.candle_last_date) |d| { + if (app.symbol_data.candleLastDate()) |d| { try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Trailing Returns: {s} (as of close on {f})", .{ app.symbol, d }), .style = th.headerStyle() }); } else { try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Trailing Returns: {s}", .{app.symbol}), .style = th.headerStyle() }); } try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); - if (app.trailing_price == null) { + if (app.symbol_data.trailing_price == null) { try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " No data. Run: zfin perf {s}", .{app.symbol}), .style = th.mutedStyle() }); return lines.toOwnedSlice(arena); } - if (app.candle_count > 0) { - if (app.candle_first_date) |first| { - if (app.candle_last_date) |last| { + if (app.symbol_data.candleCount() > 0) { + if (app.symbol_data.candleFirstDate()) |first| { + if (app.symbol_data.candleLastDate()) |last| { try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Data: {d} points ({f} to {f})", .{ - app.candle_count, first, last, + app.symbol_data.candleCount(), first, last, }), .style = th.mutedStyle() }); } } } - if (app.candles) |cc| { + if (app.symbol_data.candles) |cc| { if (cc.len > 0) { try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Latest close: {f}", .{Money.from(cc[cc.len - 1].close)}), .style = th.contentStyle() }); } } - const has_total = app.trailing_total != null; + const has_total = app.symbol_data.trailing_total != null; - if (app.candle_last_date) |last| { + if (app.symbol_data.candleLastDate()) |last| { try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " As-of {f}:", .{last}), .style = th.headerStyle() }); } - try appendStyledReturnsTable(arena, &lines, app.trailing_price.?, if (has_total) app.trailing_total else null, th); + try appendStyledReturnsTable(arena, &lines, app.symbol_data.trailing_price.?, if (has_total) app.symbol_data.trailing_total else null, th); { const today = app.today; @@ -124,8 +201,8 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Month-end ({f}):", .{month_end}), .style = th.headerStyle() }); } - if (app.trailing_me_price) |me_price| { - try appendStyledReturnsTable(arena, &lines, me_price, if (has_total) app.trailing_me_total else null, th); + if (app.symbol_data.trailing_me_price) |me_price| { + try appendStyledReturnsTable(arena, &lines, me_price, if (has_total) app.symbol_data.trailing_me_total else null, th); } if (!has_total) { @@ -133,7 +210,7 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine try lines.append(arena, .{ .text = " (Set POLYGON_API_KEY for total returns with dividends)", .style = th.dimStyle() }); } - if (app.risk_metrics) |tr| { + if (app.symbol_data.risk_metrics) |tr| { try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = " Risk Metrics (monthly returns):", .style = th.headerStyle() }); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14} {s:>14} {s:>14}", .{ "", "Volatility", "Sharpe", "Max DD" }), .style = th.mutedStyle() }); diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index 223d123..d687f68 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -8,6 +8,7 @@ const cli = @import("../commands/common.zig"); const theme = @import("theme.zig"); const tui = @import("../tui.zig"); const projections_tab = @import("projections_tab.zig"); +const analysis_tab = @import("analysis_tab.zig"); const App = tui.App; const StyledLine = tui.StyledLine; @@ -53,10 +54,10 @@ fn mapIntent(th: anytype, intent: fmt.StyleIntent) @import("vaxis").Style { /// On refresh, fetches live via svc.loadPrices. Tab switching skips this /// entirely because the portfolio_loaded guard in loadTabData() short-circuits. pub fn loadPortfolioData(app: *App) void { - app.portfolio_loaded = true; + app.portfolio.loaded = true; app.freePortfolioSummary(); - const pf = app.portfolio orelse return; + const pf = app.portfolio.file orelse return; const positions = pf.positions(app.today, app.allocator) catch { app.setStatus("Error computing positions"); @@ -90,10 +91,10 @@ pub fn loadPortfolioData(app: *App) void { } // Extract watchlist prices - if (app.watchlist_prices) |*wp| wp.clearRetainingCapacity() else { - app.watchlist_prices = std.StringHashMap(f64).init(app.allocator); + if (app.portfolio.watchlist_prices) |*wp| wp.clearRetainingCapacity() else { + app.portfolio.watchlist_prices = std.StringHashMap(f64).init(app.allocator); } - var wp = &(app.watchlist_prices.?); + var wp = &(app.portfolio.watchlist_prices.?); var pp_iter = pp.iterator(); while (pp_iter.next()) |entry| { if (!prices.contains(entry.key_ptr.*)) { @@ -105,10 +106,10 @@ pub fn loadPortfolioData(app: *App) void { app.prefetched_prices = null; } else { // Live fetch (refresh path) — fetch watchlist first, then stock prices - if (app.watchlist_prices) |*wp| wp.clearRetainingCapacity() else { - app.watchlist_prices = std.StringHashMap(f64).init(app.allocator); + if (app.portfolio.watchlist_prices) |*wp| wp.clearRetainingCapacity() else { + app.portfolio.watchlist_prices = std.StringHashMap(f64).init(app.allocator); } - var wp = &(app.watchlist_prices.?); + var wp = &(app.portfolio.watchlist_prices.?); if (app.watchlist) |wl| { for (wl) |sym| { const result = app.svc.getCandles(sym) catch continue; @@ -167,7 +168,7 @@ pub fn loadPortfolioData(app: *App) void { fetch_count = load_result.fetched_count; stale_count = load_result.stale_count; } - app.candle_last_date = latest_date; + 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) { @@ -185,8 +186,8 @@ pub fn loadPortfolioData(app: *App) void { }, }; // Transfer ownership: summary stored on App, candle_map freed after snapshots extracted - app.portfolio_summary = pf_data.summary; - app.historical_snapshots = pf_data.snapshots; + 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(); @@ -251,7 +252,7 @@ pub fn loadPortfolioData(app: *App) void { } pub fn sortPortfolioAllocations(app: *App) void { - if (app.portfolio_summary) |s| { + if (app.portfolio.summary) |s| { const SortCtx = struct { field: PortfolioSortField, dir: tui.SortDirection, @@ -279,7 +280,7 @@ pub fn rebuildPortfolioRows(app: *App) void { app.portfolio_rows.clearRetainingCapacity(); app.freePreparedSections(); - if (app.portfolio_summary) |s| { + if (app.portfolio.summary) |s| { for (s.allocations, 0..) |a, i| { // Skip allocations that don't match account filter if (!allocationMatchesFilter(app, a)) continue; @@ -293,7 +294,7 @@ pub fn rebuildPortfolioRows(app: *App) void { } } } else if (app.account_filter == null) { - if (app.portfolio) |pf| { + if (app.portfolio.file) |pf| { for (pf.lots) |lot| { if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) { lcount += 1; @@ -311,7 +312,7 @@ pub fn rebuildPortfolioRows(app: *App) void { // Only expand if multi-lot if (lcount > 1 and i < app.expanded.len and app.expanded[i]) { - if (app.portfolio) |pf| { + if (app.portfolio.file) |pf| { // Collect matching lots, sort: open first (date desc), then closed (date desc) var matching: std.ArrayList(zfin.Lot) = .empty; defer matching.deinit(app.allocator); @@ -398,14 +399,14 @@ pub fn rebuildPortfolioRows(app: *App) void { defer watch_seen.deinit(); // Mark all portfolio position symbols as seen - if (app.portfolio_summary) |s| { + if (app.portfolio.summary) |s| { for (s.allocations) |a| { watch_seen.put(a.symbol, {}) catch {}; } } // Watch lots from portfolio file - if (app.portfolio) |pf| { + if (app.portfolio.file) |pf| { for (pf.lots) |lot| { if (lot.security_type == .watch) { if (watch_seen.contains(lot.priceSymbol())) continue; @@ -432,7 +433,7 @@ pub fn rebuildPortfolioRows(app: *App) void { } // Options section (sorted by expiration date, then symbol; filtered by account) - if (app.portfolio) |pf| { + if (app.portfolio.file) |pf| { app.prepared_options = views.Options.init(app.today, app.allocator, pf.lots, app.account_filter) catch null; if (app.prepared_options) |opts| { if (opts.items.len > 0) { @@ -557,7 +558,7 @@ pub fn buildAccountList(app: *App) void { app.account_numbers.clearRetainingCapacity(); app.account_shortcut_keys.clearRetainingCapacity(); - const pf = app.portfolio orelse return; + const pf = app.portfolio.file orelse return; // Collect distinct account names from portfolio lots var seen = std.StringHashMap(void).init(app.allocator); @@ -578,7 +579,7 @@ pub fn buildAccountList(app: *App) void { app.ensureAccountMap(); // Phase 1: add accounts in accounts.srf order (if available) - if (app.account_map) |am| { + if (app.portfolio.account_map) |am| { for (am.entries) |entry| { if (seen.contains(entry.account)) { app.account_list.append(app.allocator, entry.account) catch continue; @@ -649,7 +650,7 @@ fn recomputeFilteredPositions(app: *App) void { if (app.filtered_positions) |fp| app.allocator.free(fp); app.filtered_positions = null; const filter = app.account_filter orelse return; - const pf = app.portfolio orelse return; + const pf = app.portfolio.file orelse return; app.filtered_positions = pf.positionsForAccount(app.today, app.allocator, filter) catch null; } @@ -744,7 +745,7 @@ fn computeFilteredTotals(app: *const App) FilteredTotals { const af = app.account_filter orelse return .{ .value = 0, .cost = 0 }; var value: f64 = 0; var cost: f64 = 0; - if (app.portfolio_summary) |s| { + if (app.portfolio.summary) |s| { for (s.allocations) |a| { if (allocationMatchesFilter(app, a)) { const fa = filteredAllocValues(app, a); @@ -753,7 +754,7 @@ fn computeFilteredTotals(app: *const App) FilteredTotals { } } } - if (app.portfolio) |pf| { + if (app.portfolio.file) |pf| { const ns = pf.nonStockValueForAccount(app.today, af); value += ns; cost += ns; @@ -766,7 +767,7 @@ fn computeFilteredTotals(app: *const App) FilteredTotals { pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { const th = app.theme; - if (app.portfolio == null and app.watchlist == null) { + if (app.portfolio.file == null and app.watchlist == null) { try drawWelcomeScreen(app, arena, buf, width, height); return; } @@ -774,7 +775,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width var lines: std.ArrayList(StyledLine) = .empty; try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); - if (app.portfolio_summary) |s| { + if (app.portfolio.summary) |s| { if (app.account_filter) |af| { // Filtered mode: compute account-specific totals const ft = computeFilteredTotals(app); @@ -798,7 +799,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width const summary_style = if (filtered_gl >= 0) th.positiveStyle() else th.negativeStyle(); try lines.append(arena, .{ .text = summary_text, .style = summary_style }); - if (app.candle_last_date) |d| { + if (app.portfolio.latest_quote_date) |d| { const asof_text = try std.fmt.allocPrint(arena, " (as of close on {f})", .{d}); try lines.append(arena, .{ .text = asof_text, .style = th.mutedStyle() }); } @@ -817,13 +818,13 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width try lines.append(arena, .{ .text = summary_text, .style = summary_style }); // "as of" date indicator - if (app.candle_last_date) |d| { + if (app.portfolio.latest_quote_date) |d| { const asof_text = try std.fmt.allocPrint(arena, " (as of close on {f})", .{d}); try lines.append(arena, .{ .text = asof_text, .style = th.mutedStyle() }); } // Net Worth line (only if portfolio has illiquid assets) - if (app.portfolio) |pf| { + if (app.portfolio.file) |pf| { if (pf.hasType(.illiquid)) { const illiquid_total = pf.totalIlliquid(app.today); const net_worth = zfin.valuation.netWorth(app.today, pf, s); @@ -837,7 +838,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width } // Historical portfolio value snapshots - if (app.historical_snapshots) |snapshots| { + if (app.portfolio.historical_snapshots) |snapshots| { try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); var hist_parts: [6][]const u8 = undefined; for (zfin.valuation.HistoricalPeriod.all, 0..) |period, pi| { @@ -852,7 +853,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width try lines.append(arena, .{ .text = hist_text, .style = th.mutedStyle() }); } } - } else if (app.portfolio != null) { + } else if (app.portfolio.file != null) { try lines.append(arena, .{ .text = " No cached prices. Run 'zfin perf ' for each holding.", .style = th.mutedStyle() }); } else { try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); @@ -905,7 +906,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width const is_active_sym = std.mem.eql(u8, row.symbol, app.symbol); switch (row.kind) { .position => { - if (app.portfolio_summary) |s| { + if (app.portfolio.summary) |s| { if (row.pos_idx < s.allocations.len) { const a = s.allocations[row.pos_idx]; // Use account-filtered values for multi-account positions @@ -940,7 +941,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width var date_col: []const u8 = ""; var acct_col: []const u8 = ""; if (!is_multi) { - if (app.portfolio) |pf| { + if (app.portfolio.file) |pf| { for (pf.lots) |lot| { if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) { if (matchesAccountFilter(app, lot.account)) { @@ -992,7 +993,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width var lot_gl_str: []const u8 = ""; var lot_mv_str: []const u8 = ""; var lot_positive = true; - if (app.portfolio_summary) |s| { + if (app.portfolio.summary) |s| { if (row.pos_idx < s.allocations.len) { const price = s.allocations[row.pos_idx].current_price; const use_price = lot.close_price orelse price; @@ -1028,7 +1029,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width }, .watchlist => { var price_str3: [16]u8 = undefined; - const ps: []const u8 = if (app.watchlist_prices) |wp| + const ps: []const u8 = if (app.portfolio.watchlist_prices) |wp| (if (wp.get(row.symbol)) |p| (std.fmt.bufPrint(&price_str3, "{f}", .{Money.from(p)}) catch "$?") else "--") else "--"; @@ -1074,7 +1075,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width } }, .cash_total => { - if (app.portfolio) |pf| { + if (app.portfolio.file) |pf| { const total_cash = pf.totalCash(app.today); const arrow3: []const u8 = if (app.cash_expanded) "v " else "> "; const text = try std.fmt.allocPrint(arena, " {s}Total Cash {f}", .{ @@ -1095,7 +1096,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width } }, .illiquid_total => { - if (app.portfolio) |pf| { + if (app.portfolio.file) |pf| { const total_illiquid = pf.totalIlliquid(app.today); const arrow4: []const u8 = if (app.illiquid_expanded) "v " else "> "; const text = try std.fmt.allocPrint(arena, " {s}Total Illiquid {f}", .{ @@ -1185,8 +1186,8 @@ pub fn reloadPortfolioFile(app: *App) void { app.account_list.clearRetainingCapacity(); // Re-read the portfolio file - if (app.portfolio) |*pf| pf.deinit(); - app.portfolio = null; + if (app.portfolio.file) |*pf| pf.deinit(); + app.portfolio.file = null; if (app.portfolio_path) |path| { const file_data = std.Io.Dir.cwd().readFileAlloc(app.io, path, app.allocator, .limited(10 * 1024 * 1024)) catch { app.setStatus("Error reading portfolio file"); @@ -1194,7 +1195,7 @@ pub fn reloadPortfolioFile(app: *App) void { }; defer app.allocator.free(file_data); if (zfin.cache.deserializePortfolio(app.allocator, file_data)) |pf| { - app.portfolio = pf; + app.portfolio.file = pf; } else |_| { app.setStatus("Error parsing portfolio file"); return; @@ -1220,7 +1221,7 @@ pub fn reloadPortfolioFile(app: *App) void { app.scroll_offset = 0; app.portfolio_rows.clearRetainingCapacity(); - const pf = app.portfolio orelse return; + const pf = app.portfolio.file orelse return; const positions = pf.positions(app.today, app.allocator) catch { app.setStatus("Error computing positions"); return; @@ -1252,7 +1253,7 @@ pub fn reloadPortfolioFile(app: *App) void { missing += 1; } } - app.candle_last_date = latest_date; + app.portfolio.latest_quote_date = latest_date; // Build portfolio summary, candle map, and historical snapshots from cache var pf_data = cli.buildPortfolioData(app.allocator, pf, positions, syms, &prices, app.svc, app.today) catch |err| switch (err) { @@ -1269,8 +1270,8 @@ pub fn reloadPortfolioFile(app: *App) void { return; }, }; - app.portfolio_summary = pf_data.summary; - app.historical_snapshots = pf_data.snapshots; + app.portfolio.summary = pf_data.summary; + app.portfolio.historical_snapshots = pf_data.snapshots; { var it = pf_data.candle_map.valueIterator(); while (it.next()) |v| app.allocator.free(v.*); @@ -1283,15 +1284,17 @@ pub fn reloadPortfolioFile(app: *App) void { rebuildPortfolioRows(app); // Invalidate analysis data -- it holds pointers into old portfolio memory - if (app.analysis_result) |*ar| ar.deinit(app.allocator); - app.analysis_result = null; - app.analysis_loaded = false; - app.analysis_disabled = false; // Portfolio loaded; analysis is now possible + if (app.states.analysis.result) |*ar| ar.deinit(app.allocator); + app.states.analysis.result = null; + app.states.analysis.loaded = false; + // Note: `analysis_tab.tab.isDisabled` derives availability from + // `app.portfolio.file`, so we don't need to clear a `disabled` + // flag here — it's recomputed at every read. // If currently on the analysis tab, eagerly recompute so the user // doesn't see an error message before switching away and back. if (app.active_tab == .analysis) { - app.loadAnalysisData(); + analysis_tab.tab.activate(&app.states.analysis, app) catch {}; } // Invalidate projections data — projections.srf may have changed diff --git a/src/tui/projections_tab.zig b/src/tui/projections_tab.zig index af7b009..bb42522 100644 --- a/src/tui/projections_tab.zig +++ b/src/tui/projections_tab.zig @@ -52,7 +52,7 @@ pub fn loadData(app: *App) void { const portfolio_dir = portfolio_path[0..dir_end]; // As-of mode — load historical snapshot + ctx. This path is - // independent of `app.portfolio_summary` / `app.portfolio` because + // independent of `app.portfolio.summary` / `app.portfolio` because // the snapshot's own totals and lot composition are the source of // truth for the projection. // @@ -113,14 +113,14 @@ pub fn loadData(app: *App) void { // Imported-only as-of: scale today's allocations to // the imported liquid total. Requires the live // portfolio summary, which the portfolio tab loads - // up-front into `app.portfolio_summary`. - const summary = app.portfolio_summary orelse { + // up-front into `app.portfolio.summary`. + const summary = app.portfolio.summary orelse { app.setStatus("Imported as-of needs live portfolio — visit Portfolio tab first"); app.projections_as_of = null; app.projections_as_of_requested = null; break :as_of; }; - const portfolio = app.portfolio orelse { + const portfolio = app.portfolio.file orelse { app.setStatus("Imported as-of needs live portfolio — visit Portfolio tab first"); app.projections_as_of = null; app.projections_as_of_requested = null; @@ -168,12 +168,12 @@ pub fn loadData(app: *App) void { // Live path. Reached either because no as-of was requested OR the // as-of branch above bailed and fell through after clearing state. - const summary = app.portfolio_summary orelse { + const summary = app.portfolio.summary orelse { app.setStatus("No portfolio summary — visit Portfolio tab first"); return; }; - const portfolio = app.portfolio orelse return; + const portfolio = app.portfolio.file orelse return; const ctx = view.loadProjectionContext( app.io, @@ -267,7 +267,7 @@ pub fn drawContent(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, wi const arena = ctx.arena; // Determine whether to use Kitty graphics - const use_kitty = switch (app.chart.config.mode) { + const use_kitty = switch (app.chart_config.mode) { .braille => false, .kitty => true, .auto => if (app.vx_app) |va| va.vx.caps.kitty_graphics else false, @@ -352,8 +352,8 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, const px_h: u32 = @as(u32, chart_rows) * cell_h; if (px_w < 100 or px_h < 100) return; - const capped_w = @min(px_w, app.chart.config.max_width); - const capped_h = @min(px_h, app.chart.config.max_height); + const capped_w = @min(px_w, app.chart_config.max_width); + const capped_h = @min(px_h, app.chart_config.max_height); // Render or reuse cached image if (app.projections_chart_dirty) { diff --git a/src/tui/quote_tab.zig b/src/tui/quote_tab.zig index 539549c..3cca4b3 100644 --- a/src/tui/quote_tab.zig +++ b/src/tui/quote_tab.zig @@ -6,11 +6,164 @@ const Money = @import("../Money.zig"); const theme = @import("theme.zig"); const chart = @import("chart.zig"); const tui = @import("../tui.zig"); +const framework = @import("tab_framework.zig"); const App = tui.App; const StyledLine = tui.StyledLine; const glyph = tui.glyph; +// ── Tab-local action enum ───────────────────────────────────── +// +// Quote tab cycles the chart timeframe with `[` and `]` (chart- +// next / chart-prev). These bindings only fire on the quote tab +// today; the surrounding `if (active_tab == .quote)` gate will +// disappear when scoped keymaps land in step 3. + +pub const Action = enum { + chart_timeframe_next, + chart_timeframe_prev, +}; + +// ── Tab-private state ───────────────────────────────────────── + +pub const State = struct { + /// Stored real-time quote (only fetched on manual refresh; not + /// auto-refetched on every redraw). + live: ?zfin.Quote = null, + /// Unix-epoch seconds for the live-quote fetch — drives the + /// "data Xs ago" header readout. + timestamp: i64 = 0, + /// Pixel-chart state (Kitty graphics + Bollinger bands + + /// 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 = .{}, +}; + +// ── Tab framework contract ──────────────────────────────────── + +pub const tab = struct { + pub const ActionT = Action; + pub const StateT = State; + + pub const default_bindings: []const framework.TabBinding(Action) = &.{ + .{ .action = .chart_timeframe_next, .key = .{ .codepoint = ']' } }, + .{ .action = .chart_timeframe_prev, .key = .{ .codepoint = '[' } }, + }; + + pub const action_labels = std.enums.EnumArray(Action, []const u8).init(.{ + .chart_timeframe_next = "Chart: next timeframe", + .chart_timeframe_prev = "Chart: previous timeframe", + }); + + pub const status_hints: []const Action = &.{ + .chart_timeframe_next, + }; + + pub fn init(state: *State, app: *App) !void { + _ = app; + state.* = .{}; + } + + pub fn deinit(state: *State, app: *App) void { + state.chart.freeCache(app.allocator); + state.* = .{}; + } + + /// Quote loads its own data on activation (the live-quote + /// fetch path lives in tui.zig after the tab switches because + /// it depends on App.svc); no-op here. Chart redraws are + /// triggered by the dirty flag on `state.chart`. + pub fn activate(state: *State, app: *App) !void { + _ = state; + _ = app; + } + + pub const deactivate = framework.noopDeactivate(State); + + /// Refresh: invalidate candles cache, drop the live quote, + /// mark chart dirty so the next draw re-renders. + pub fn reload(state: *State, app: *App) !void { + state.live = null; + state.timestamp = 0; + state.chart.dirty = true; + state.chart.freeCache(app.allocator); + } + + pub const tick = framework.noopTick(State); + + pub fn handleAction(state: *State, app: *App, action: Action) void { + switch (action) { + .chart_timeframe_next => { + state.chart.timeframe = state.chart.timeframe.next(); + state.chart.dirty = true; + app.setStatus(state.chart.timeframe.label()); + }, + .chart_timeframe_prev => { + state.chart.timeframe = state.chart.timeframe.prev(); + state.chart.dirty = true; + app.setStatus(state.chart.timeframe.label()); + }, + } + } + + /// Mouse handling: clicks on the timeframe selector row switch + /// the chart timeframe. Returns `true` if the click was on a + /// timeframe label (consumed); `false` otherwise (unhandled). + /// The caller (App's mouse dispatcher) handles wheel scroll, + /// tab-bar clicks, and other global mouse semantics before + /// routing here. + pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool { + if (mouse.button != .left) return false; + if (mouse.type != .press) return false; + if (mouse.row == 0) return false; // tab bar — App handles + const tf_row = state.chart.timeframe_row orelse return false; + const content_row = @as(usize, @intCast(mouse.row)) + app.scroll_offset; + if (content_row != tf_row) return false; + + // Layout: " Chart: [6M] YTD 1Y 3Y 5Y ([ ] to change)" + // Prefix " Chart: " is 9 chars. Each timeframe label takes + // `label_len + 2` (brackets/spaces around the label) + 1 (gap). + const col = @as(usize, @intCast(mouse.col)); + const prefix_len: usize = 9; + if (col < prefix_len) return false; + + const timeframes = [_]chart.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; + if (col >= x and col < x + slot_width) { + if (tf != state.chart.timeframe) { + state.chart.timeframe = tf; + app.setStatus(tf.label()); + } + return true; + } + x += slot_width; + } + return false; + } + + pub fn isDisabled(app: *App) bool { + _ = app; + return false; + } + + /// Symbol-change reset. Drops the live quote, resets the + /// fetch timestamp, marks the chart dirty so the next draw + /// re-renders for the new symbol, and frees the indicator + /// cache (Bollinger bands etc. are computed per-symbol). + /// The candle data lives on `app.symbol_data` and is dropped + /// centrally by the App. + pub fn onSymbolChange(state: *State, app: *App) void { + state.live = null; + state.timestamp = 0; + state.chart.dirty = true; + state.chart.freeCache(app.allocator); + } +}; + // ── Rendering ───────────────────────────────────────────────── /// Draw the quote tab content. Uses Kitty graphics for the chart when available, @@ -19,13 +172,13 @@ pub fn drawContent(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, wi const arena = ctx.arena; // Determine whether to use Kitty graphics - const use_kitty = switch (app.chart.config.mode) { + const use_kitty = switch (app.chart_config.mode) { .braille => false, .kitty => true, .auto => if (app.vx_app) |va| va.vx.caps.kitty_graphics else false, }; - if (use_kitty and app.candles != null and app.candles.?.len >= 40) { + if (use_kitty and app.symbol_data.candles != null and app.symbol_data.candles.?.len >= 40) { drawWithKittyChart(app, ctx, buf, width, height) catch { // On any failure, fall back to braille try app.drawStyledContent(arena, buf, width, height, try buildStyledLines(app, arena)); @@ -40,14 +193,14 @@ pub fn drawContent(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, wi fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void { const arena = ctx.arena; const th = app.theme; - const c = app.candles orelse return; + const c = app.symbol_data.candles orelse return; // Build text header (symbol, price, change) — first few lines var lines: std.ArrayList(StyledLine) = .empty; try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); // Symbol + price header - if (app.quote) |q| { + if (app.states.quote.live) |q| { const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2}", .{ app.symbol, q.close }); try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() }); if (q.previous_close > 0) { @@ -81,7 +234,7 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, const timeframes = [_]chart.Timeframe{ .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" }; for (timeframes) |tf| { const lbl = tf.label(); - if (tf == app.chart.timeframe) { + if (tf == app.states.quote.chart.timeframe) { tf_buf[tf_pos] = '['; tf_pos += 1; @memcpy(tf_buf[tf_pos..][0..lbl.len], lbl); @@ -102,7 +255,7 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, const hint = " ([ ] to change)"; @memcpy(tf_buf[tf_pos..][0..hint.len], hint); tf_pos += hint.len; - app.chart.timeframe_row = lines.items.len; // track which row the timeframe line is on + app.states.quote.chart.timeframe_row = lines.items.len; // track which row the timeframe line is on try lines.append(arena, .{ .text = try arena.dupe(u8, tf_buf[0..tf_pos]), .style = th.mutedStyle() }); } @@ -130,76 +283,76 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, if (px_w < 100 or px_h < 100) return; // Apply resolution cap from chart config - const capped_w = @min(px_w, app.chart.config.max_width); - const capped_h = @min(px_h, app.chart.config.max_height); + const capped_w = @min(px_w, app.chart_config.max_width); + const capped_h = @min(px_h, app.chart_config.max_height); // Check if we need to re-render the chart image - const symbol_changed = app.chart.symbol_len != app.symbol.len or - !std.mem.eql(u8, app.chart.symbol[0..app.chart.symbol_len], app.symbol); - const tf_changed = app.chart.timeframe_rendered == null or app.chart.timeframe_rendered.? != app.chart.timeframe; + const symbol_changed = app.states.quote.chart.symbol_len != app.symbol.len or + !std.mem.eql(u8, app.states.quote.chart.symbol[0..app.states.quote.chart.symbol_len], app.symbol); + const tf_changed = app.states.quote.chart.timeframe_rendered == null or app.states.quote.chart.timeframe_rendered.? != app.states.quote.chart.timeframe; - if (app.chart.dirty or symbol_changed or tf_changed) { + if (app.states.quote.chart.dirty or symbol_changed or tf_changed) { // Free old image - if (app.chart.image_id) |old_id| { + if (app.states.quote.chart.image_id) |old_id| { if (app.vx_app) |va| { va.vx.freeImage(va.tty.writer(), old_id); } - app.chart.image_id = null; + app.states.quote.chart.image_id = null; } // If symbol changed, invalidate the indicator cache if (symbol_changed) { - app.chart.freeCache(app.allocator); + app.states.quote.chart.freeCache(app.allocator); } // Check if we can reuse cached indicators - const cache_valid = app.chart.isCacheValid(c, app.chart.timeframe); + const cache_valid = app.states.quote.chart.isCacheValid(c, app.states.quote.chart.timeframe); // If cache is invalid, compute new indicators if (!cache_valid) { // Free old cache if it exists - app.chart.freeCache(app.allocator); + app.states.quote.chart.freeCache(app.allocator); // Compute and cache new indicators const new_cache = chart.computeIndicators( app.allocator, c, - app.chart.timeframe, + app.states.quote.chart.timeframe, ) catch |err| { - app.chart.dirty = false; + app.states.quote.chart.dirty = false; var err_buf: [128]u8 = undefined; const msg = std.fmt.bufPrint(&err_buf, "Indicator computation failed: {s}", .{@errorName(err)}) catch "Indicator computation failed"; app.setStatus(msg); return; }; - app.chart.cached_indicators = new_cache; + app.states.quote.chart.cached_indicators = new_cache; // Update cache metadata - const max_days = app.chart.timeframe.tradingDays(); + const max_days = app.states.quote.chart.timeframe.tradingDays(); const n = @min(c.len, max_days); const data = c[c.len - n ..]; - app.chart.cache_candle_count = data.len; - app.chart.cache_timeframe = app.chart.timeframe; - app.chart.cache_last_close = if (data.len > 0) data[data.len - 1].close else 0; + app.states.quote.chart.cache_candle_count = data.len; + app.states.quote.chart.cache_timeframe = app.states.quote.chart.timeframe; + app.states.quote.chart.cache_last_close = if (data.len > 0) data[data.len - 1].close else 0; } // Render and transmit — use the app's main allocator, NOT the arena, // because z2d allocates large pixel buffers that would bloat the arena. if (app.vx_app) |va| { // Pass cached indicators to avoid recomputation during rendering - const cached_ptr: ?*const chart.CachedIndicators = if (app.chart.cached_indicators) |*ci| ci else null; + const cached_ptr: ?*const chart.CachedIndicators = if (app.states.quote.chart.cached_indicators) |*ci| ci else null; const chart_result = chart.renderChart( app.io, app.allocator, c, - app.chart.timeframe, + app.states.quote.chart.timeframe, capped_w, capped_h, th, cached_ptr, ) catch |err| { - app.chart.dirty = false; + app.states.quote.chart.dirty = false; var err_buf: [128]u8 = undefined; const msg = std.fmt.bufPrint(&err_buf, "Chart render failed: {s}", .{@errorName(err)}) catch "Chart render failed"; app.setStatus(msg); @@ -211,7 +364,7 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, // This avoids the PNG encode → file write → file read → PNG decode roundtrip. const base64_enc = std.base64.standard.Encoder; const b64_buf = app.allocator.alloc(u8, base64_enc.calcSize(chart_result.rgb_data.len)) catch { - app.chart.dirty = false; + app.states.quote.chart.dirty = false; app.setStatus("Chart: base64 alloc failed"); return; }; @@ -225,31 +378,31 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, chart_result.height, .rgb, ) catch |err| { - app.chart.dirty = false; + app.states.quote.chart.dirty = false; var err_buf: [128]u8 = undefined; const msg = std.fmt.bufPrint(&err_buf, "Image transmit failed: {s}", .{@errorName(err)}) catch "Image transmit failed"; app.setStatus(msg); return; }; - app.chart.image_id = img.id; - app.chart.image_width = @intCast(chart_cols); - app.chart.image_height = chart_rows; + app.states.quote.chart.image_id = img.id; + app.states.quote.chart.image_width = @intCast(chart_cols); + app.states.quote.chart.image_height = chart_rows; // Track what we rendered const sym_len = @min(app.symbol.len, 16); - @memcpy(app.chart.symbol[0..sym_len], app.symbol[0..sym_len]); - app.chart.symbol_len = sym_len; - app.chart.timeframe_rendered = app.chart.timeframe; - app.chart.price_min = chart_result.price_min; - app.chart.price_max = chart_result.price_max; - app.chart.rsi_latest = chart_result.rsi_latest; - app.chart.dirty = false; + @memcpy(app.states.quote.chart.symbol[0..sym_len], app.symbol[0..sym_len]); + app.states.quote.chart.symbol_len = sym_len; + app.states.quote.chart.timeframe_rendered = app.states.quote.chart.timeframe; + app.states.quote.chart.price_min = chart_result.price_min; + app.states.quote.chart.price_max = chart_result.price_max; + app.states.quote.chart.rsi_latest = chart_result.rsi_latest; + app.states.quote.chart.dirty = false; } } // Place the image in the cell buffer - if (app.chart.image_id) |img_id| { + if (app.states.quote.chart.image_id) |img_id| { // Place image at the first cell of the chart area const chart_row_start: usize = header_rows; const chart_col_start: usize = 1; // 1 col left margin @@ -262,8 +415,8 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, .img_id = img_id, .options = .{ .size = .{ - .rows = app.chart.image_height, - .cols = app.chart.image_width, + .rows = app.states.quote.chart.image_height, + .cols = app.states.quote.chart.image_width, }, .scale = .contain, }, @@ -274,17 +427,17 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, // ── Axis labels (terminal text in the right margin) ─────────── // The chart image uses layout fractions: price=72%, gap=8%, RSI=20% // Map these to terminal rows to position labels. - const img_rows = app.chart.image_height; - const label_col: usize = @as(usize, chart_col_start) + @as(usize, app.chart.image_width) + 1; + const img_rows = app.states.quote.chart.image_height; + const label_col: usize = @as(usize, chart_col_start) + @as(usize, app.states.quote.chart.image_width) + 1; const label_style = th.mutedStyle(); - if (label_col + 8 <= width and img_rows >= 4 and app.chart.price_max > app.chart.price_min) { + if (label_col + 8 <= width and img_rows >= 4 and app.states.quote.chart.price_max > app.states.quote.chart.price_min) { // Price axis labels — evenly spaced across the price panel (top 72%) const price_panel_rows = @as(f64, @floatFromInt(img_rows)) * 0.72; const n_price_labels: usize = 5; for (0..n_price_labels) |i| { const frac = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n_price_labels - 1)); - const price_val = app.chart.price_max - frac * (app.chart.price_max - app.chart.price_min); + const price_val = app.states.quote.chart.price_max - frac * (app.states.quote.chart.price_max - app.states.quote.chart.price_min); const row_f = @as(f64, @floatFromInt(chart_row_start)) + frac * price_panel_rows; const row: usize = @intFromFloat(@round(row_f)); if (row >= height) continue; @@ -332,13 +485,13 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, } // Render quote details below the chart image as styled text - const detail_start_row = header_rows + app.chart.image_height; + const detail_start_row = header_rows + app.states.quote.chart.image_height; if (detail_start_row + 8 < height) { var detail_lines: std.ArrayList(StyledLine) = .empty; try detail_lines.append(arena, .{ .text = "", .style = th.contentStyle() }); const latest = c[c.len - 1]; - const quote_data = app.quote; + const quote_data = app.states.quote.live; const price = if (quote_data) |q| q.close else latest.close; const prev_close = if (quote_data) |q| q.previous_close else if (c.len >= 2) c[c.len - 2].close else @as(f64, 0); @@ -367,13 +520,12 @@ fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine { } var ago_buf: [16]u8 = undefined; - if (app.quote != null and app.quote_timestamp > 0) { - // wall-clock required: per-frame "now" for the "refreshed Xs ago" - // readout on the live quote header. + if (app.states.quote.live != null and app.states.quote.timestamp > 0) { + // wall-clock required: per-frame "now" for the data-age readout. const now_s = std.Io.Timestamp.now(app.io, .real).toSeconds(); - const ago_str = fmt.fmtTimeAgo(&ago_buf, app.quote_timestamp, now_s); + const ago_str = fmt.fmtTimeAgo(&ago_buf, app.states.quote.timestamp, now_s); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s} (live, ~15 min delay, refreshed {s})", .{ app.symbol, ago_str }), .style = th.headerStyle() }); - } else if (app.candle_last_date) |d| { + } else if (app.symbol_data.candleLastDate()) |d| { try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s} (as of close on {f})", .{ app.symbol, d }), .style = th.headerStyle() }); } else { try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s}", .{app.symbol}), .style = th.headerStyle() }); @@ -381,9 +533,9 @@ fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine { try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); // Use stored real-time quote if available (fetched on manual refresh) - const quote_data = app.quote; + const quote_data = app.states.quote.live; - const c = app.candles orelse { + const c = app.symbol_data.candles orelse { if (quote_data) |q| { // No candle data but have a quote - show it try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Price: {f}", .{Money.from(q.close)}), .style = th.contentStyle() }); @@ -492,7 +644,7 @@ fn buildDetailColumns( var col4 = Column.init(); // Top holdings col4.width = 30; - if (app.etf_profile) |profile| { + if (app.symbol_data.etf_profile) |profile| { // Col 2: ETF key stats try col2.add(arena, "ETF Profile", th.headerStyle()); if (profile.expense_ratio) |er| { diff --git a/src/tui/tab_framework.zig b/src/tui/tab_framework.zig index a0cfe1b..308ce55 100644 --- a/src/tui/tab_framework.zig +++ b/src/tui/tab_framework.zig @@ -47,6 +47,30 @@ //! pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool { ... } //! pub fn handlePaste(state: *State, app: *App, text: []const u8) bool { ... } //! +//! // ── Context-change hooks (optional) ───────────────────── +//! // Fire when a global context this tab depends on changes. +//! // Tabs that don't care simply omit the method. Contrast with +//! // `reload` (drops data AND triggers a fetch); these hooks +//! // drop data but DON'T trigger a fetch — the fetch happens +//! // lazily on next `activate`. +//! pub fn onSymbolChange(state: *State, app: *App) void { ... } +//! +//! /// Fired when the user invokes a global scroll-to-extreme +//! /// action (`g`/`G`). Tabs with a cursor reset it to match +//! /// the new scroll position. Tabs without a cursor omit +//! /// this hook. +//! pub fn onScroll(state: *State, app: *App, where: ScrollEdge) void { ... } +//! +//! /// Fired when the user invokes a relative cursor-move +//! /// (`j`/`k`, ↑/↓, mouse wheel). `delta` is signed: positive +//! /// = down, negative = up. Magnitude is 1 for keys, larger +//! /// for wheel events. Tabs with a row cursor step it, +//! /// clamp to row count, and ensure visibility; return +//! /// `true` to consume. Tabs without a cursor (or with empty +//! /// rows) return `false` so the framework falls through to +//! /// scroll-by-`delta` instead. +//! pub fn onCursorMove(state: *State, app: *App, delta: isize) bool { ... } +//! //! // ── Misc (required) ───────────────────────────────────── //! pub fn isDisabled(app: *App) bool { ... } //! }; @@ -56,8 +80,9 @@ //! empty `default_bindings` / `action_labels` / `status_hints`. //! No implicit defaults — the contract is fully explicit for //! action-related fields and lifecycle hooks. The event hooks -//! (`handleKey`, `handleMouse`, `handlePaste`) are the exception: -//! their absence means "this tab doesn't process that event class." +//! (`handleKey`, `handleMouse`, `handlePaste`) and context-change +//! hooks (`onSymbolChange`) are the exception: their absence means +//! "this tab doesn't process that event class." //! //! Lifecycle hooks that aren't meaningful for a given tab can use //! the `noop*` factory helpers below to inherit no-op @@ -91,6 +116,12 @@ pub fn TabBinding(comptime ActionT: type) type { }; } +/// Argument passed to `onScroll` indicating which extreme the +/// user scrolled to. `top` corresponds to the global `scroll_top` +/// action (default: `g`); `bottom` corresponds to `scroll_bottom` +/// (default: `G`). +pub const ScrollEdge = enum { top, bottom }; + // ── Lifecycle hook factories (no-op defaults) ───────────────── // // Tabs that don't need a particular lifecycle hook can declare @@ -98,9 +129,10 @@ pub fn TabBinding(comptime ActionT: type) type { // This keeps the contract explicit (every required field is named // in the tab struct) while letting tabs avoid writing dummy bodies. // -// Event hooks (handleKey, handleMouse, handlePaste) are NOT in -// this list — they're optional via `@hasDecl` checking, so a tab -// that doesn't care simply omits the method. +// Event hooks (handleKey, handleMouse, handlePaste) and context- +// change hooks (onSymbolChange) are NOT in this list — they're +// optional via `@hasDecl` checking, so a tab that doesn't care +// simply omits the method. const App = @import("../tui.zig").App; @@ -308,6 +340,35 @@ pub fn validateTabModule(comptime Module: type) void { "pub fn handlePaste(state: *State, app: *App, text: []const u8) bool { ... }", ); } + + // ── Context-change hooks (optional, typed when present) ── + if (@hasDecl(tab_decl, "onSymbolChange")) { + expectFn( + mod_name, + tab_decl, + "onSymbolChange", + fn (*State, *App) void, + "pub fn onSymbolChange(state: *State, app: *App) void { ... }", + ); + } + if (@hasDecl(tab_decl, "onScroll")) { + expectFn( + mod_name, + tab_decl, + "onScroll", + fn (*State, *App, ScrollEdge) void, + "pub fn onScroll(state: *State, app: *App, where: ScrollEdge) void { ... }", + ); + } + if (@hasDecl(tab_decl, "onCursorMove")) { + expectFn( + mod_name, + tab_decl, + "onCursorMove", + fn (*State, *App, isize) bool, + "pub fn onCursorMove(state: *State, app: *App, delta: isize) bool { ... }", + ); + } } }