begin cleanup on remaining tab/app separation of concerns

This commit is contained in:
Emil Lerch 2026-05-15 17:05:59 -07:00
parent 0c3ddd1ffc
commit 15ae2cbf20
Signed by: lobo
GPG key ID: A7B62D657EF764F8
6 changed files with 104 additions and 96 deletions

View file

@ -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.
///

View file

@ -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);

View file

@ -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);

View file

@ -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);
}

View file

@ -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);

View file

@ -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);