From 15ae2cbf205048310368e93c67099486cc7de8f7 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Fri, 15 May 2026 17:05:59 -0700 Subject: [PATCH] begin cleanup on remaining tab/app separation of concerns --- src/tui.zig | 140 ++++++++++++++---------------------- src/tui/earnings_tab.zig | 9 ++- src/tui/options_tab.zig | 3 + src/tui/performance_tab.zig | 17 +++++ src/tui/portfolio_tab.zig | 10 ++- src/tui/quote_tab.zig | 21 ++++-- 6 files changed, 104 insertions(+), 96 deletions(-) diff --git a/src/tui.zig b/src/tui.zig index 0b28e56..7b56349 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -868,6 +868,29 @@ pub const App = struct { } } + /// Dispatch to a fallible (`!void`-returning) hook on the + /// active tab. Errors are swallowed — matches the existing + /// `tab.activate(...) catch {}` idiom that this dispatcher + /// replaces. Use this for `activate`/`reload`/etc. where the + /// caller doesn't have a meaningful recovery path. + /// + /// The contract validator already required `activate`/`reload` + /// to exist on every tab, so the `@hasDecl` guard is mostly + /// future-proofing for newly-added fallible hooks that aren't + /// universally required. + fn dispatchTry(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) catch {}; + 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 @@ -1580,55 +1603,31 @@ pub const App = struct { } fn refreshCurrentTab(self: *App) void { - // Invalidate cache so the next load forces a fresh fetch - if (self.symbol.len > 0) { - switch (self.active_tab) { - .quote, .performance => { - self.svc.invalidate(self.symbol, .candles_daily); - self.svc.invalidate(self.symbol, .dividends); - }, - .earnings => { - self.svc.invalidate(self.symbol, .earnings); - }, - .options => { - self.svc.invalidate(self.symbol, .options); - }, - .portfolio, .analysis, .history, .projections => {}, - } - } - switch (self.active_tab) { - .portfolio => { - self.portfolio.loaded = false; - self.freePortfolioSummary(); - }, - .quote, .performance => { - 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 => { - tab_modules.earnings.tab.reload(&self.states.earnings, self) catch {}; - }, - .options => { - tab_modules.options.tab.reload(&self.states.options, self) catch {}; - }, - .analysis => { - tab_modules.analysis.tab.reload(&self.states.analysis, self) catch {}; - }, - .history => { - tab_modules.history.tab.reload(&self.states.history, self) catch {}; - }, - .projections => { - tab_modules.projections.tab.reload(&self.states.projections, self) catch {}; - }, - } - self.loadTabData(); + // Each tab's `reload` hook owns its full per-tab refresh + // sequence: invalidate the svc cache, drop in-memory + // state, and re-fetch via `loadData` (or whatever the + // tab's loader is named). The framework dispatcher routes + // to the active tab's reload; tabs that share data + // (quote/performance) delegate via their reload bodies. + // + // Reload is contractually self-completing: when it + // returns, the tab's state is fully refreshed. There's no + // need for a follow-up `loadTabData` — `activate` would + // see `state.loaded = true` and no-op. + self.dispatchTry("reload", .{}); - // After reload, fetch live quote for active symbol (costs 1 API call) + // Live-quote re-fetch: the quote tab's freshness display + // ("refreshed Xs ago") is driven by `states.quote.timestamp`, + // which is independent of the candles cache. The user's + // mental model for `r` includes "and the price ticker is + // current as of NOW," so we hit the live-quote endpoint + // here. Only on tabs that show a live quote. + // + // This is the one place where `r` reaches past the cache; + // tab-switches that activate quote/performance don't trigger + // it (they should keep using whatever cached price the user + // last fetched). When live-streaming quotes ship someday, + // this block goes away. switch (self.active_tab) { .quote, .performance => { if (self.symbol.len > 0) { @@ -1645,33 +1644,12 @@ pub const App = struct { } } + /// Activate the current tab — load any data it needs, set + /// any per-tab UI state. Each tab's `activate` is responsible + /// for self-gating (e.g. skipping when `app.symbol.len == 0` + /// or when its data is already cached). pub fn loadTabData(self: *App) void { - switch (self.active_tab) { - .portfolio => { - tab_modules.portfolio.tab.activate(&self.states.portfolio, self) catch {}; - }, - .quote, .performance => { - if (self.symbol.len == 0) return; - tab_modules.performance.tab.activate(&self.states.performance, self) catch {}; - }, - .earnings => { - if (self.symbol.len == 0) return; - tab_modules.earnings.tab.activate(&self.states.earnings, self) catch {}; - }, - .options => { - if (self.symbol.len == 0) return; - tab_modules.options.tab.activate(&self.states.options, self) catch {}; - }, - .analysis => { - tab_modules.analysis.tab.activate(&self.states.analysis, self) catch {}; - }, - .history => { - tab_modules.history.tab.activate(&self.states.history, self) catch {}; - }, - .projections => { - tab_modules.projections.tab.activate(&self.states.projections, self) catch {}; - }, - } + self.dispatchTry("activate", .{}); } pub fn loadPortfolioData(self: *App) void { @@ -2244,20 +2222,6 @@ pub fn renderBrailleToStyledLines(arena: std.mem.Allocator, lines: *std.ArrayLis pub const loadWatchlist = cli.loadWatchlist; pub const freeWatchlist = cli.freeWatchlist; -// Force test discovery for imported TUI sub-modules -comptime { - _ = keybinds; - _ = theme; - _ = tab_modules.portfolio; - _ = tab_modules.quote; - _ = tab_modules.performance; - _ = tab_modules.options; - _ = tab_modules.earnings; - _ = tab_modules.analysis; - _ = tab_modules.history; - _ = tab_modules.projections; -} - /// Write the full default keymap to `out` in keys.srf format, /// covering both the global section and each tab's local bindings. /// diff --git a/src/tui/earnings_tab.zig b/src/tui/earnings_tab.zig index 9b7db77..972a441 100644 --- a/src/tui/earnings_tab.zig +++ b/src/tui/earnings_tab.zig @@ -76,6 +76,7 @@ pub const tab = struct { /// loads on first activation per symbol; subsequent activations /// for the same symbol short-circuit on `loaded`. pub fn activate(state: *State, app: *App) !void { + if (app.symbol.len == 0) return; if (state.disabled) return; if (state.loaded) return; loadData(state, app); @@ -85,9 +86,13 @@ pub const tab = struct { pub const deactivate = framework.noopDeactivate(State); /// Force re-fetch on user request (refresh keybind, symbol - /// change, etc). Frees current payload + clears flags + - /// re-runs the fetch path. + /// change, etc). Invalidates the svc cache for earnings, + /// frees current payload, clears flags, and re-runs the + /// fetch path. pub fn reload(state: *State, app: *App) !void { + if (app.symbol.len > 0) { + app.svc.invalidate(app.symbol, .earnings); + } // Clear every flag so loadData has the same starting // conditions as a fresh activation. if (state.data) |e| app.allocator.free(e); diff --git a/src/tui/options_tab.zig b/src/tui/options_tab.zig index 5629bd2..e7bbb96 100644 --- a/src/tui/options_tab.zig +++ b/src/tui/options_tab.zig @@ -135,6 +135,9 @@ pub const tab = struct { pub const deactivate = framework.noopDeactivate(State); pub fn reload(state: *State, app: *App) !void { + if (app.symbol.len > 0) { + app.svc.invalidate(app.symbol, .options); + } // Drop chains first so loadData starts clean. if (state.chains) |chains| { zfin.OptionsChain.freeSlice(app.allocator, chains); diff --git a/src/tui/performance_tab.zig b/src/tui/performance_tab.zig index c605f42..01e1a9a 100644 --- a/src/tui/performance_tab.zig +++ b/src/tui/performance_tab.zig @@ -60,7 +60,24 @@ pub const tab = struct { pub const deactivate = framework.noopDeactivate(State); + /// Manual refresh: invalidate the shared svc cache for candles + /// and dividends so the next `loadData` re-fetches from + /// network, then drop in-memory copies and the chart cache + /// shared with the quote tab. Quote and performance share + /// `app.symbol_data`; quote piggybacks on this reload via its + /// own delegating reload. pub fn reload(state: *State, app: *App) !void { + if (app.symbol.len > 0) { + app.svc.invalidate(app.symbol, .candles_daily); + app.svc.invalidate(app.symbol, .dividends); + } + // The chart is rendered by the quote tab but is fed from + // `app.symbol_data.candles` which performance owns. After + // a refresh the next quote draw must re-render and the + // indicator overlay cache (SMA/Bollinger/etc) must drop. + app.states.quote.chart.dirty = true; + app.states.quote.chart.freeCache(app.allocator); + state.loaded = false; loadData(state, app); } diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index 0ac0093..5f1919b 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -181,8 +181,16 @@ pub const tab = struct { pub const deactivate = framework.noopDeactivate(State); + /// Manual refresh (r/F5): drop the cached aggregate summary + /// and re-fetch live prices via `loadPortfolioData`. Distinct + /// from `reloadPortfolioFile` (R), which re-reads + /// `portfolio.srf` from disk. The framework calls this from + /// `refreshCurrentTab`; the file-reload path has its own + /// separate action. pub fn reload(state: *State, app: *App) !void { - reloadPortfolioFile(state, app); + app.portfolio.loaded = false; + app.freePortfolioSummary(); + loadPortfolioData(state, app); } pub const tick = framework.noopTick(State); diff --git a/src/tui/quote_tab.zig b/src/tui/quote_tab.zig index 34a0484..774c035 100644 --- a/src/tui/quote_tab.zig +++ b/src/tui/quote_tab.zig @@ -77,20 +77,31 @@ pub const tab = struct { /// 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`. + /// Quote and performance share `app.symbol_data` (candles + + /// dividends). Performance owns the loader; quote piggybacks + /// by delegating its activate to performance's. This keeps + /// `loadTabData`'s dispatch uniform — every tab activates its + /// own state — while preserving the historical "switching to + /// quote populates shared candle data" behavior. pub fn activate(state: *State, app: *App) !void { _ = state; - _ = app; + const perf_module = @import("performance_tab.zig"); + try perf_module.tab.activate(&app.states.performance, app); } pub const deactivate = framework.noopDeactivate(State); - /// Refresh: invalidate candles cache, drop the live quote, - /// mark chart dirty so the next draw re-renders. + /// Refresh: delegate to performance.reload, which owns the + /// shared candle/dividend data and svc invalidation. Quote's + /// chart-state (dirty + freeCache) is also reset by + /// performance.reload — see the comment there for why. + /// Quote-only state (live quote + timestamp) is reset here + /// because performance doesn't know about it. pub fn reload(state: *State, app: *App) !void { state.live = null; state.timestamp = 0; - state.chart.dirty = true; - state.chart.freeCache(app.allocator); + const perf_module = @import("performance_tab.zig"); + try perf_module.tab.reload(&app.states.performance, app); } pub const tick = framework.noopTick(State);