move most tabs to new framework

This commit is contained in:
Emil Lerch 2026-05-14 14:50:36 -07:00
parent 4ffddb44a2
commit dd6fad9184
10 changed files with 1260 additions and 575 deletions

View file

@ -255,6 +255,31 @@ Ask the user instead.**
on files with uncommitted work, unless the user asks for that specific on files with uncommitted work, unless the user asks for that specific
operation by name. 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. ### NEVER run `git add`, `git commit`, or `git push`. EVER.
- **The user commits. You do not.** Do not stage files. Do not create commits. - **The user commits. You do not.** Do not stage files. Do not create commits.

View file

@ -250,7 +250,6 @@ pub const OptionsRow = struct {
}; };
pub const ChartState = struct { pub const ChartState = struct {
config: chart.ChartConfig = .{},
timeframe: chart.Timeframe = .@"1Y", timeframe: chart.Timeframe = .@"1Y",
image_id: ?u32 = null, // currently transmitted Kitty image ID image_id: ?u32 = null, // currently transmitted Kitty image ID
image_width: u16 = 0, // image width in cells image_width: u16 = 0, // image width in cells
@ -315,6 +314,10 @@ pub const ChartState = struct {
/// `TODO.md`'s "Refactor: TUI tab framework" entry. /// `TODO.md`'s "Refactor: TUI tab framework" entry.
pub const TabStates = struct { pub const TabStates = struct {
earnings: earnings_tab.State = .{}, 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 /// Comptime registry of all tab modules conforming to the
@ -347,6 +350,10 @@ pub const TabStates = struct {
/// proven out tab-by-tab. /// proven out tab-by-tab.
const tab_modules = .{ const tab_modules = .{
.earnings = earnings_tab, .earnings = earnings_tab,
.analysis = analysis_tab,
.quote = quote_tab,
.performance = perf_tab,
.options = options_tab,
}; };
comptime { comptime {
@ -365,6 +372,16 @@ comptime {
/// single tab doesn't own this data — it's a shared cache scoped /// single tab doesn't own this data — it's a shared cache scoped
/// to "the current symbol." Cleared in `resetSymbolData` whenever /// to "the current symbol." Cleared in `resetSymbolData` whenever
/// the user changes symbols. /// 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 { pub const SymbolData = struct {
/// Daily OHLCV candles for the active symbol, oldest-first. /// Daily OHLCV candles for the active symbol, oldest-first.
/// Owned by SymbolData; freed via `deinit` or `clear`. /// 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 /// Account-tax-type metadata loaded from `accounts.srf` next
/// to the portfolio. Used by analysis (tax-type breakdown) /// to the portfolio. Used by analysis (tax-type breakdown)
/// and portfolio (per-account display). /// 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, account_map: ?zfin.analysis.AccountMap = null,
/// Cached prices for watchlist symbols (no live fetching during /// Cached prices for watchlist symbols (no live fetching during
/// render). Populated on portfolio load and refresh. /// render). Populated on portfolio load and refresh.
@ -511,6 +539,15 @@ pub const App = struct {
/// `app.states.<tab>`; tabs not yet migrated still have their /// `app.states.<tab>`; tabs not yet migrated still have their
/// fields directly on App. /// fields directly on App.
states: TabStates = .{}, 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 /// Captured at App init and refreshed at tab change. Using a cached
/// date (rather than calling the clock on every render) keeps render /// date (rather than calling the clock on every render) keeps render
/// deterministic within a single frame and avoids threading `io` /// 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 has_explicit_symbol: bool = false, // true if -s was used
portfolio: ?zfin.Portfolio = null,
portfolio_path: ?[]const u8 = null, portfolio_path: ?[]const u8 = null,
watchlist: ?[][]const u8 = null, watchlist: ?[][]const u8 = null,
watchlist_path: ?[]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_line_count: usize = 0, // total styled lines in portfolio view
portfolio_sort_field: PortfolioSortField = .symbol, // current sort column portfolio_sort_field: PortfolioSortField = .symbol, // current sort column
portfolio_sort_dir: SortDirection = .asc, // current sort direction 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) prefetched_prices: ?std.StringHashMap(f64) = null, // prices loaded before TUI starts (with stderr progress)
// Account filter state // 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_matches: std.ArrayList(usize) = .empty, // indices into account_list matching search
account_search_cursor: usize = 0, // cursor within search_matches 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 tab state
history_loaded: bool = false, history_loaded: bool = false,
history_disabled: bool = false, // true when no portfolio path (history requires it) 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. // Terminals often send multiple wheel events per physical tick.
last_wheel_ns: i128 = 0, last_wheel_ns: i128 = 0,
// Chart state (Kitty graphics) /// Global chart-rendering config (mode, max dimensions). Driven
chart: ChartState = .{}, /// 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 vx_app: ?*vaxis.vxfw.App = null, // set during run(), for Kitty graphics access
pub fn widget(self: *App) vaxis.vxfw.Widget { 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
// Portfolio tab: click header to sort, click row to expand/collapse
if (self.active_tab == .portfolio and mouse.row > 0) { if (self.active_tab == .portfolio and mouse.row > 0) {
const content_row = @as(usize, @intCast(mouse.row)) + self.scroll_offset; const content_row = @as(usize, @intCast(mouse.row)) + self.scroll_offset;
// Click on column header row -> sort by that column // 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 // Framework dispatch: ask the active tab's `handleMouse`
if (self.active_tab == .quote and mouse.row > 0) { // (when defined) if it wants to consume this click. Tabs
if (self.chart.timeframe_row) |tf_row| { // without a `handleMouse` decl simply fall through.
const content_row = @as(usize, @intCast(mouse.row)) + self.scroll_offset; if (self.dispatchBool("handleMouse", .{mouse})) {
if (content_row == tf_row) { return ctx.consumeAndRedraw();
// " 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;
}
}
} }
// History tab: click a tier header to expand/collapse; // History tab: click a tier header to expand/collapse;
// click a bucket/snapshot row to move the cursor. // 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 /// Outcome of a single keypress in an input-mode buffer (symbol
/// input, date input, etc.). Returned by `handleInputBuffer` so /// input, date input, etc.). Returned by `handleInputBuffer` so
/// the per-mode caller only needs to wire up the `committed` /// 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. /// Load accounts.srf if not already loaded. Derives path from portfolio_path.
pub fn ensureAccountMap(self: *App) void { 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; 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. /// Set or clear the account filter. Owns the string via allocator.
@ -1282,7 +1340,7 @@ pub const App = struct {
if (name) |n| { if (name) |n| {
self.account_filter = self.allocator.dupe(u8, n) catch null; 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; self.filtered_positions = pf.positionsForAccount(self.today, self.allocator, n) catch null;
} }
} else { } else {
@ -1397,7 +1455,7 @@ pub const App = struct {
self.toggleExpand(); self.toggleExpand();
return ctx.consumeAndRedraw(); return ctx.consumeAndRedraw();
} else if (self.active_tab == .options) { } else if (self.active_tab == .options) {
self.toggleOptionsExpand(); options_tab.tab.handleAction(&self.states.options, self, options_tab.Action.expand_collapse);
return ctx.consumeAndRedraw(); return ctx.consumeAndRedraw();
} else if (self.active_tab == .history) { } else if (self.active_tab == .history) {
if (history_tab.toggleTierAtCursor(self)) { if (history_tab.toggleTierAtCursor(self)) {
@ -1429,15 +1487,14 @@ pub const App = struct {
.scroll_top => { .scroll_top => {
self.scroll_offset = 0; self.scroll_offset = 0;
if (self.active_tab == .portfolio) self.cursor = 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(); return ctx.consumeAndRedraw();
}, },
.scroll_bottom => { .scroll_bottom => {
self.scroll_offset = std.math.maxInt(usize) / 2; // clamped during draw...divide by 2 to avoid overflow if arithmetic is done 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) if (self.active_tab == .portfolio and self.portfolio_rows.items.len > 0)
self.cursor = self.portfolio_rows.items.len - 1; self.cursor = self.portfolio_rows.items.len - 1;
if (self.active_tab == .options and self.options_rows.items.len > 0) self.dispatchVoid("onScroll", .{tab_framework.ScrollEdge.bottom});
self.options_cursor = self.options_rows.items.len - 1;
return ctx.consumeAndRedraw(); return ctx.consumeAndRedraw();
}, },
.help => { .help => {
@ -1451,40 +1508,36 @@ pub const App = struct {
}, },
.collapse_all_calls => { .collapse_all_calls => {
if (self.active_tab == .options) { 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(); return ctx.consumeAndRedraw();
} }
}, },
.collapse_all_puts => { .collapse_all_puts => {
if (self.active_tab == .options) { 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(); 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 => { .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) { if (self.active_tab == .options) {
const n = @intFromEnum(action) - @intFromEnum(keybinds.Action.options_filter_1) + 1; const n = @intFromEnum(action) - @intFromEnum(keybinds.Action.options_filter_1) + 1;
self.options_near_the_money = n; // Route through tab.handleAction so the dispatch
self.rebuildOptionsRows(); // table is consistent with the framework. Each
var tmp_buf: [256]u8 = undefined; // filter_N maps directly to options_tab.Action.filter_N.
const msg = std.fmt.bufPrint(&tmp_buf, "Filtered to +/- {d} strikes NTM", .{n}) catch "Filtered"; const tab_action: options_tab.Action = @enumFromInt(@intFromEnum(options_tab.Action.filter_1) + n - 1);
self.setStatus(msg); options_tab.tab.handleAction(&self.states.options, self, tab_action);
return ctx.consumeAndRedraw(); return ctx.consumeAndRedraw();
} }
}, },
.chart_timeframe_next => { .chart_timeframe_next => {
if (self.active_tab == .quote) { if (self.active_tab == .quote) {
self.chart.timeframe = self.chart.timeframe.next(); quote_tab.tab.handleAction(&self.states.quote, self, quote_tab.Action.chart_timeframe_next);
self.chart.dirty = true;
self.setStatus(self.chart.timeframe.label());
return ctx.consumeAndRedraw(); return ctx.consumeAndRedraw();
} }
}, },
.chart_timeframe_prev => { .chart_timeframe_prev => {
if (self.active_tab == .quote) { if (self.active_tab == .quote) {
self.chart.timeframe = self.chart.timeframe.prev(); quote_tab.tab.handleAction(&self.states.quote, self, quote_tab.Action.chart_timeframe_prev);
self.chart.dirty = true;
self.setStatus(self.chart.timeframe.label());
return ctx.consumeAndRedraw(); return ctx.consumeAndRedraw();
} }
}, },
@ -1561,7 +1614,7 @@ pub const App = struct {
} }
}, },
.account_filter => { .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; self.mode = .account_picker;
// Position cursor on the currently-active filter (or 0 for "All") // Position cursor on the currently-active filter (or 0 for "All")
self.account_picker_cursor = 0; self.account_picker_cursor = 0;
@ -1650,32 +1703,42 @@ pub const App = struct {
} }
/// Move cursor/scroll. Positive = down, negative = up. /// 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. /// 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 { 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.shouldDebounceWheel()) return;
if (self.active_tab == .portfolio) { stepCursor(&self.cursor, self.portfolio_rows.items.len, n);
stepCursor(&self.cursor, self.portfolio_rows.items.len, n); self.ensureCursorVisible();
self.ensureCursorVisible(); return;
} else { }
stepCursor(&self.options_cursor, self.options_rows.items.len, n); if (self.active_tab == .history and self.history_compare_view == null and self.history_table_row_count > 0) {
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).
if (self.shouldDebounceWheel()) return; if (self.shouldDebounceWheel()) return;
stepCursor(&self.history_cursor, self.history_table_row_count, n); stepCursor(&self.history_cursor, self.history_table_row_count, n);
self.ensureHistoryCursorVisible(); 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 { } else {
if (n > 0) { const abs: usize = @intCast(-n);
self.scroll_offset += @intCast(n); if (self.scroll_offset > abs) self.scroll_offset -= abs else self.scroll_offset = 0;
} else {
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 { pub fn setActiveSymbol(self: *App, sym: []const u8) void {
const len = @min(sym.len, self.symbol_buf.len); const len = @min(sym.len, self.symbol_buf.len);
@memcpy(self.symbol_buf[0..len], sym[0..len]); @memcpy(self.symbol_buf[0..len], sym[0..len]);
@ -1882,36 +1817,16 @@ pub const App = struct {
} }
fn resetSymbolData(self: *App) void { 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 // Tab-private symbol-bound state is dropped via each tab's
// onSymbolChange hook (where defined). Distinct from // onSymbolChange hook (where defined). Distinct from
// `tab.deinit` (App teardown) and `tab.reload` (drops AND // `tab.deinit` (App teardown) and `tab.reload` (drops AND
// re-fetches) — these hooks just drop the cache; the next // re-fetches) — these hooks just drop the cache; the next
// `activate` will re-fetch lazily. // `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.scroll_offset = 0;
self.chart.dirty = true;
self.chart.freeCache(self.allocator); // Invalidate indicator cache
} }
fn refreshCurrentTab(self: *App) void { fn refreshCurrentTab(self: *App) void {
@ -1933,29 +1848,26 @@ pub const App = struct {
} }
switch (self.active_tab) { switch (self.active_tab) {
.portfolio => { .portfolio => {
self.portfolio_loaded = false; self.portfolio.loaded = false;
self.freePortfolioSummary(); self.freePortfolioSummary();
}, },
.quote, .performance => { .quote, .performance => {
self.perf_loaded = false; self.states.performance.loaded = false;
self.freeCandles(); if (self.symbol_data.candles) |c| self.allocator.free(c);
self.freeDividends(); self.symbol_data.candles = null;
self.chart.dirty = true; if (self.symbol_data.dividends) |d| zfin.Dividend.freeSlice(self.allocator, d);
self.chart.freeCache(self.allocator); // Invalidate indicator cache self.symbol_data.dividends = null;
self.states.quote.chart.dirty = true;
self.states.quote.chart.freeCache(self.allocator); // Invalidate indicator cache
}, },
.earnings => { .earnings => {
earnings_tab.tab.reload(&self.states.earnings, self) catch {}; earnings_tab.tab.reload(&self.states.earnings, self) catch {};
}, },
.options => { .options => {
self.options_loaded = false; options_tab.tab.reload(&self.states.options, self) catch {};
self.freeOptions();
}, },
.analysis => { .analysis => {
self.analysis_loaded = false; analysis_tab.tab.reload(&self.states.analysis, self) catch {};
if (self.analysis_result) |*ar| ar.deinit(self.allocator);
self.analysis_result = null;
if (self.account_map) |*am| am.deinit();
self.account_map = null;
}, },
.history => { .history => {
self.history_loaded = false; self.history_loaded = false;
@ -1973,11 +1885,11 @@ pub const App = struct {
.quote, .performance => { .quote, .performance => {
if (self.symbol.len > 0) { if (self.symbol.len > 0) {
if (self.svc.getQuote(self.symbol)) |q| { if (self.svc.getQuote(self.symbol)) |q| {
self.quote = q; self.states.quote.live = q;
// wall-clock required: records the exact moment // wall-clock required: records the exact moment
// this quote was served so the "refreshed Xs ago" // this quote was served so the "refreshed Xs ago"
// display is honest about freshness. // 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 |_| {} } else |_| {}
} }
}, },
@ -1986,14 +1898,13 @@ pub const App = struct {
} }
fn loadTabData(self: *App) void { fn loadTabData(self: *App) void {
self.data_error = null;
switch (self.active_tab) { switch (self.active_tab) {
.portfolio => { .portfolio => {
if (!self.portfolio_loaded) self.loadPortfolioData(); if (!self.portfolio.loaded) self.loadPortfolioData();
}, },
.quote, .performance => { .quote, .performance => {
if (self.symbol.len == 0) return; if (self.symbol.len == 0) return;
if (!self.perf_loaded) perf_tab.loadData(self); perf_tab.tab.activate(&self.states.performance, self) catch {};
}, },
.earnings => { .earnings => {
if (self.symbol.len == 0) return; if (self.symbol.len == 0) return;
@ -2001,11 +1912,10 @@ pub const App = struct {
}, },
.options => { .options => {
if (self.symbol.len == 0) return; if (self.symbol.len == 0) return;
if (!self.options_loaded) options_tab.loadData(self); options_tab.tab.activate(&self.states.options, self) catch {};
}, },
.analysis => { .analysis => {
if (self.analysis_disabled) return; analysis_tab.tab.activate(&self.states.analysis, self) catch {};
if (!self.analysis_loaded) self.loadAnalysisData();
}, },
.history => { .history => {
if (self.history_disabled) return; if (self.history_disabled) return;
@ -2041,44 +1951,9 @@ pub const App = struct {
return self.status_msg[0..self.status_len]; 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 { pub fn freePortfolioSummary(self: *App) void {
if (self.portfolio_summary) |*s| s.deinit(self.allocator); if (self.portfolio.summary) |*s| s.deinit(self.allocator);
self.portfolio_summary = null; self.portfolio.summary = null;
} }
pub fn freePreparedSections(self: *App) void { pub fn freePreparedSections(self: *App) void {
@ -2089,28 +1964,22 @@ pub const App = struct {
} }
fn deinitData(self: *App) void { fn deinitData(self: *App) void {
self.freeCandles(); self.symbol_data.deinit(self.allocator);
self.freeDividends();
self.freeOptions();
self.freeEtfProfile();
self.freePortfolioSummary();
earnings_tab.tab.deinit(&self.states.earnings, self); earnings_tab.tab.deinit(&self.states.earnings, self);
options_tab.tab.deinit(&self.states.options, self);
self.freePreparedSections(); self.freePreparedSections();
self.portfolio_rows.deinit(self.allocator); self.portfolio_rows.deinit(self.allocator);
self.options_rows.deinit(self.allocator);
self.account_list.deinit(self.allocator); self.account_list.deinit(self.allocator);
self.account_numbers.deinit(self.allocator); self.account_numbers.deinit(self.allocator);
self.account_shortcut_keys.deinit(self.allocator); self.account_shortcut_keys.deinit(self.allocator);
self.account_search_matches.deinit(self.allocator); self.account_search_matches.deinit(self.allocator);
if (self.account_filter) |af| self.allocator.free(af); if (self.account_filter) |af| self.allocator.free(af);
if (self.filtered_positions) |fp| self.allocator.free(fp); if (self.filtered_positions) |fp| self.allocator.free(fp);
if (self.watchlist_prices) |*wp| wp.deinit(); analysis_tab.tab.deinit(&self.states.analysis, self);
if (self.analysis_result) |*ar| ar.deinit(self.allocator); self.portfolio.deinit(self.allocator);
if (self.classification_map) |*cm| cm.deinit();
if (self.account_map) |*am| am.deinit();
history_tab.freeLoaded(self); history_tab.freeLoaded(self);
projections_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 { 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 = &.{} }; 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 { fn isTabDisabled(self: *App, t: Tab) bool {
return (t == .earnings and earnings_tab.tab.isDisabled(self)) or return self.appPredicate(t, "isDisabled") or
(t == .analysis and self.analysis_disabled) or
(t == .history and self.history_disabled) or (t == .history and self.history_disabled) or
(t == .projections and self.projections_disabled); (t == .projections and self.projections_disabled);
} }
@ -2386,10 +2261,6 @@ pub const App = struct {
// ── Analysis tab ──────────────────────────────────────────── // ── Analysis tab ────────────────────────────────────────────
pub fn loadAnalysisData(self: *App) void {
analysis_tab.loadData(self);
}
fn buildAnalysisStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { fn buildAnalysisStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
return analysis_tab.buildStyledLines(self, arena); return analysis_tab.buildStyledLines(self, arena);
} }
@ -2726,7 +2597,7 @@ pub fn run(
.portfolio_path = portfolio_path, .portfolio_path = portfolio_path,
.symbol = symbol, .symbol = symbol,
.has_explicit_symbol = has_explicit_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), .history_expanded_buckets = std.AutoHashMap(history_tab.BucketKey, void).init(allocator),
}; };
defer app_inst.history_expanded_buckets.deinit(); defer app_inst.history_expanded_buckets.deinit();
@ -2736,7 +2607,7 @@ pub fn run(
if (file_data) |d| { if (file_data) |d| {
defer allocator.free(d); defer allocator.free(d);
if (zfin.cache.deserializePortfolio(allocator, d)) |pf| { if (zfin.cache.deserializePortfolio(allocator, d)) |pf| {
app_inst.portfolio = pf; app_inst.portfolio.file = pf;
} else |_| {} } else |_| {}
} }
} }
@ -2761,9 +2632,11 @@ pub fn run(
app_inst.active_tab = .quote; app_inst.active_tab = .quote;
} }
// Disable analysis tab when no portfolio is loaded (analysis requires portfolio) // Disable projections tab when no portfolio is loaded.
if (app_inst.portfolio == null) { // Analysis derives the same condition via its `isDisabled`
app_inst.analysis_disabled = true; // 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; app_inst.projections_disabled = true;
} }
// History tab also requires a portfolio path to locate the // 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. // Pre-fetch portfolio prices before TUI starts, with stderr progress.
// This runs while the terminal is still in normal mode so output is visible. // 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; const syms = pf.stockSymbols(allocator) catch null;
defer if (syms) |s| allocator.free(s); defer if (syms) |s| allocator.free(s);
@ -2822,12 +2695,11 @@ pub fn run(
// without requiring the user to visit the portfolio tab // without requiring the user to visit the portfolio tab
// first. Cheap (pure compute + cache reads) once prices are // first. Cheap (pure compute + cache reads) once prices are
// already in hand. // already in hand.
if (app_inst.portfolio != null) { if (app_inst.portfolio.file != null) {
portfolio_tab.loadPortfolioData(app_inst); portfolio_tab.loadPortfolioData(app_inst);
} }
} }
defer if (app_inst.portfolio) |*pf| pf.deinit();
defer freeWatchlist(allocator, app_inst.watchlist); defer freeWatchlist(allocator, app_inst.watchlist);
defer app_inst.deinitData(); defer app_inst.deinitData();
@ -2842,9 +2714,9 @@ pub fn run(
defer app_inst.vx_app = null; defer app_inst.vx_app = null;
defer { defer {
// Free any chart image before vaxis is torn down // 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); 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| { if (app_inst.projections_image_id) |id| {
vx_app.vx.freeImage(vx_app.tty.writer(), id); vx_app.vx.freeImage(vx_app.tty.writer(), id);

View file

@ -5,21 +5,109 @@ const fmt = @import("../format.zig");
const Money = @import("../Money.zig"); const Money = @import("../Money.zig");
const theme = @import("theme.zig"); const theme = @import("theme.zig");
const tui = @import("../tui.zig"); const tui = @import("../tui.zig");
const framework = @import("tab_framework.zig");
const App = tui.App; const App = tui.App;
const StyledLine = tui.StyledLine; 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 ────────────────────────────────────────────── // ── Data loading ──────────────────────────────────────────────
pub fn loadData(app: *App) void { fn loadData(state: *State, app: *App) void {
app.analysis_loaded = true; state.loaded = true;
// Ensure portfolio is loaded first // Ensure portfolio is loaded first
if (!app.portfolio_loaded) app.loadPortfolioData(); if (!app.portfolio.loaded) app.loadPortfolioData();
const pf = app.portfolio orelse return; const pf = app.portfolio.file orelse return;
const summary = app.portfolio_summary orelse return; const summary = app.portfolio.summary orelse return;
// Load classification metadata file // Load classification metadata file
if (app.classification_map == null) { if (state.classification_map == null) {
// Look for metadata.srf next to the portfolio file // Look for metadata.srf next to the portfolio file
if (app.portfolio_path) |ppath| { if (app.portfolio_path) |ppath| {
// Derive metadata path: same directory as portfolio, named "metadata.srf" // 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); 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"); app.setStatus("Error parsing metadata.srf");
return; return;
}; };
@ -43,25 +131,25 @@ pub fn loadData(app: *App) void {
// Load account tax type metadata file (optional) // Load account tax type metadata file (optional)
app.ensureAccountMap(); app.ensureAccountMap();
loadDataFinish(app, pf, summary); loadDataFinish(state, app, pf, summary);
} }
fn loadDataFinish(app: *App, pf: zfin.Portfolio, summary: zfin.valuation.PortfolioSummary) void { fn loadDataFinish(state: *State, app: *App, pf: zfin.Portfolio, summary: zfin.valuation.PortfolioSummary) void {
const cm = app.classification_map orelse { const cm = state.classification_map orelse {
app.setStatus("No classification data. Run: zfin enrich <portfolio.srf> > metadata.srf"); app.setStatus("No classification data. Run: zfin enrich <portfolio.srf> > metadata.srf");
return; return;
}; };
// Free previous result // 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, app.allocator,
summary.allocations, summary.allocations,
cm, cm,
pf, pf,
summary.total_value, summary.total_value,
app.account_map, app.portfolio.account_map,
app.today, // live mode in TUI → resolves to app.today app.today, // live mode in TUI → resolves to app.today
) catch { ) catch {
app.setStatus("Error computing analysis"); app.setStatus("Error computing analysis");
@ -72,15 +160,16 @@ fn loadDataFinish(app: *App, pf: zfin.Portfolio, summary: zfin.valuation.Portfol
// ── Rendering ───────────────────────────────────────────────── // ── Rendering ─────────────────────────────────────────────────
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine { pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
const state = &app.states.analysis;
// Compute equity/fixed split from classification + portfolio // Compute equity/fixed split from classification + portfolio
var stock_pct: f64 = 0; var stock_pct: f64 = 0;
var bond_pct: f64 = 0; var bond_pct: f64 = 0;
var total_value: f64 = 0; var total_value: f64 = 0;
if (app.portfolio_summary) |summary| { if (app.portfolio.summary) |summary| {
total_value = summary.total_value; total_value = summary.total_value;
if (app.portfolio) |pf| { if (app.portfolio.file) |pf| {
const benchmark = @import("../analytics/benchmark.zig"); 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( const split = benchmark.deriveAllocationSplit(
summary.allocations, summary.allocations,
cm_entries, cm_entries,
@ -92,7 +181,7 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine
bond_pct = split.bond_pct; 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. /// 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.expectEqual(@as(usize, 5), lines.len);
try testing.expect(std.mem.indexOf(u8, lines[3].text, "No analysis data") != null); 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);
}

View file

@ -21,7 +21,7 @@
//! - Esc — exit compare view if active, else clear pending selections. //! - Esc — exit compare view if active, else clear pending selections.
//! //!
//! The "today (live)" pseudo-row is conditional: it appears as the //! 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. //! are populated. When present, `history_cursor = 0` points at it.
//! //!
//! Consumes `src/analytics/timeline.zig` (pure compute) and //! 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) { if (older.is_live) {
var map: compare_view.HoldingMap = .init(app.allocator); var map: compare_view.HoldingMap = .init(app.allocator);
errdefer map.deinit(); errdefer map.deinit();
try aggregateFromSummary(app.portfolio_summary.?, &map); try aggregateFromSummary(app.portfolio.summary.?, &map);
resources.then_live_map = map; resources.then_live_map = map;
then_map_ptr = &resources.then_live_map.?; then_map_ptr = &resources.then_live_map.?;
then_liquid = liveLiquid(app); then_liquid = liveLiquid(app);
@ -353,7 +353,7 @@ fn buildCompareFromSelections(app: *App, sel_a: usize, sel_b: usize) !void {
if (newer.is_live) { if (newer.is_live) {
var map: compare_view.HoldingMap = .init(app.allocator); var map: compare_view.HoldingMap = .init(app.allocator);
errdefer map.deinit(); errdefer map.deinit();
try aggregateFromSummary(app.portfolio_summary.?, &map); try aggregateFromSummary(app.portfolio.summary.?, &map);
resources.now_live_map = map; resources.now_live_map = map;
now_map_ptr = &resources.now_live_map.?; now_map_ptr = &resources.now_live_map.?;
now_liquid = liveLiquid(app); 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 { 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; 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 /// Build the live row for the cascading path. Uses the newest
/// bucket (typically a daily bucket) as the comparison anchor. /// bucket (typically a daily bucket) as the comparison anchor.
fn buildLiveRowFromCascading(app: *const App, buckets: []const timeline.TierBucket) ?TableRow { fn buildLiveRowFromCascading(app: *const App, buckets: []const timeline.TierBucket) ?TableRow {
if (app.portfolio == null) return null; if (app.portfolio.file == null) return null;
const summary = app.portfolio_summary orelse return null; const summary = app.portfolio.summary orelse return null;
const liquid = summary.total_value; 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; const net_worth = liquid + illiquid;
var d_liquid: ?f64 = null; 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 /// Deltas are computed against the newest snapshot in `deltas` (which
/// is index `deltas.len - 1` — deltas is oldest-first). /// is index `deltas.len - 1` — deltas is oldest-first).
fn buildLiveRow(app: *const App, deltas: []const timeline.RowDelta) ?TableRow { fn buildLiveRow(app: *const App, deltas: []const timeline.RowDelta) ?TableRow {
if (app.portfolio == null) return null; if (app.portfolio.file == null) return null;
const summary = app.portfolio_summary orelse return null; const summary = app.portfolio.summary orelse return null;
const liquid = summary.total_value; 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; const net_worth = liquid + illiquid;
// Deltas vs. the most recent snapshot. // Deltas vs. the most recent snapshot.

View file

@ -5,15 +5,274 @@ const fmt = @import("../format.zig");
const Money = @import("../Money.zig"); const Money = @import("../Money.zig");
const theme = @import("theme.zig"); const theme = @import("theme.zig");
const tui = @import("../tui.zig"); const tui = @import("../tui.zig");
const framework = @import("tab_framework.zig");
const App = tui.App; const App = tui.App;
const StyledLine = tui.StyledLine; 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 ────────────────────────────────────────────── // ── Data loading ──────────────────────────────────────────────
pub fn loadData(app: *App) void { fn loadData(state: *State, app: *App) void {
app.options_loaded = true; state.loaded = true;
app.freeOptions(); if (state.chains) |chains| {
zfin.OptionsChain.freeSlice(app.allocator, chains);
}
state.chains = null;
const result = app.svc.getOptions(app.symbol) catch |err| { const result = app.svc.getOptions(app.symbol) catch |err| {
switch (err) { switch (err) {
@ -22,19 +281,155 @@ pub fn loadData(app: *App) void {
} }
return; return;
}; };
app.options_data = result.data; state.chains = result.data;
app.options_timestamp = result.timestamp; state.timestamp = result.timestamp;
app.options_cursor = 0; state.cursor = 0;
app.options_expanded = @splat(false); state.expanded = @splat(false);
app.options_calls_collapsed = @splat(false); state.calls_collapsed = @splat(false);
app.options_puts_collapsed = @splat(false); state.puts_collapsed = @splat(false);
app.rebuildOptionsRows(); rebuildRows(state, app);
app.setStatus(if (result.source == .cached) "Cached (1hr TTL) | r/F5 to refresh" else "Fetched | r/F5 to refresh"); 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 ───────────────────────────────────────────────── // ── Rendering ─────────────────────────────────────────────────
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine { pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
const state = &app.states.options;
const th = app.theme; const th = app.theme;
var lines: std.ArrayList(StyledLine) = .empty; 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); 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() }); try lines.append(arena, .{ .text = " Loading options data...", .style = th.mutedStyle() });
return lines.toOwnedSlice(arena); 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 // readout. Captured here rather than on `app` so it refreshes every
// time this tab renders. // time this tab renders.
const now_s = std.Io.Timestamp.now(app.io, .real).toSeconds(); 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) { 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() }); 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 { } else {
@ -68,22 +463,22 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine
} }
if (chains[0].underlying_price) |price| { 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() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// Track header line count for mouse click mapping (after all non-data lines) // 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 // Flat list of options rows with inline expand/collapse
for (app.options_rows.items, 0..) |row, ri| { for (state.rows.items, 0..) |row, ri| {
const is_cursor = ri == app.options_cursor; const is_cursor = ri == state.cursor;
switch (row.kind) { switch (row.kind) {
.expiration => { .expiration => {
if (row.exp_idx < chains.len) { if (row.exp_idx < chains.len) {
const chain = chains[row.exp_idx]; 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 is_monthly = fmt.isMonthlyExpiration(chain.expiration);
const arrow: []const u8 = if (is_expanded) "v " else "> "; const arrow: []const u8 = if (is_expanded) "v " else "> ";
const text = try std.fmt.allocPrint(arena, " {s}{f} ({d} calls, {d} puts)", .{ 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 => { .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 arrow: []const u8 = if (calls_collapsed) " > " else " v ";
const style = if (is_cursor) th.selectStyle() else th.headerStyle(); 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", .{ 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 }); }), .style = style });
}, },
.puts_header => { .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 "; const arrow: []const u8 = if (puts_collapsed) " > " else " v ";
try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
const style = if (is_cursor) th.selectStyle() else th.headerStyle(); const style = if (is_cursor) th.selectStyle() else th.headerStyle();

View file

@ -5,23 +5,100 @@ const fmt = @import("../format.zig");
const Money = @import("../Money.zig"); const Money = @import("../Money.zig");
const theme = @import("theme.zig"); const theme = @import("theme.zig");
const tui = @import("../tui.zig"); const tui = @import("../tui.zig");
const framework = @import("tab_framework.zig");
const App = tui.App; const App = tui.App;
const StyledLine = tui.StyledLine; 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 ────────────────────────────────────────────── // ── Data loading ──────────────────────────────────────────────
pub fn loadData(app: *App) void { fn loadData(state: *State, app: *App) void {
app.perf_loaded = true; state.loaded = true;
app.freeCandles(); if (app.symbol_data.candles) |c| app.allocator.free(c);
app.freeDividends(); app.symbol_data.candles = null;
app.trailing_price = null; if (app.symbol_data.dividends) |d| zfin.Dividend.freeSlice(app.allocator, d);
app.trailing_total = null; app.symbol_data.dividends = null;
app.trailing_me_price = null; app.symbol_data.trailing_price = null;
app.trailing_me_total = null; app.symbol_data.trailing_total = null;
app.candle_count = 0; app.symbol_data.trailing_me_price = null;
app.candle_first_date = null; app.symbol_data.trailing_me_total = null;
app.candle_last_date = null;
const result = app.svc.getTrailingReturns(app.symbol) catch |err| { const result = app.svc.getTrailingReturns(app.symbol) catch |err| {
switch (err) { switch (err) {
@ -33,35 +110,35 @@ pub fn loadData(app: *App) void {
} }
return; return;
}; };
app.candles = result.candles; app.symbol_data.candles = result.candles;
app.candle_timestamp = result.timestamp; app.symbol_data.candle_timestamp = result.timestamp;
const c = result.candles; const c = result.candles;
if (c.len == 0) { if (c.len == 0) {
app.setStatus("No data available for symbol"); app.setStatus("No data available for symbol");
return; return;
} }
app.candle_count = c.len; // candle_count / candle_first_date / candle_last_date are derived
app.candle_first_date = c[0].date; // from `candles` via methods on SymbolData — no field assignments
app.candle_last_date = c[c.len - 1].date; // needed here.
app.trailing_price = result.asof_price; app.symbol_data.trailing_price = result.asof_price;
app.trailing_me_price = result.me_price; app.symbol_data.trailing_me_price = result.me_price;
app.trailing_total = result.asof_total; app.symbol_data.trailing_total = result.asof_total;
app.trailing_me_total = result.me_total; app.symbol_data.trailing_me_total = result.me_total;
if (result.dividends) |divs| { 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) // Try to load ETF profile (non-fatal, won't show for non-ETFs)
if (!app.etf_loaded) { if (!app.symbol_data.etf_loaded) {
app.etf_loaded = true; app.symbol_data.etf_loaded = true;
if (app.svc.getEtfProfile(app.symbol)) |etf_result| { if (app.svc.getEtfProfile(app.symbol)) |etf_result| {
if (etf_result.data.isEtf()) { if (etf_result.data.isEtf()) {
app.etf_profile = etf_result.data; app.symbol_data.etf_profile = etf_result.data;
} }
} else |_| {} } else |_| {}
} }
@ -82,41 +159,41 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine
return lines.toOwnedSlice(arena); 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() }); 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 { } else {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Trailing Returns: {s}", .{app.symbol}), .style = th.headerStyle() }); 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() }); 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() }); 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); return lines.toOwnedSlice(arena);
} }
if (app.candle_count > 0) { if (app.symbol_data.candleCount() > 0) {
if (app.candle_first_date) |first| { if (app.symbol_data.candleFirstDate()) |first| {
if (app.candle_last_date) |last| { if (app.symbol_data.candleLastDate()) |last| {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Data: {d} points ({f} to {f})", .{ 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() }); }), .style = th.mutedStyle() });
} }
} }
} }
if (app.candles) |cc| { if (app.symbol_data.candles) |cc| {
if (cc.len > 0) { 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() }); 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 = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " As-of {f}:", .{last}), .style = th.headerStyle() }); 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; 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 = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Month-end ({f}):", .{month_end}), .style = th.headerStyle() }); 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| { if (app.symbol_data.trailing_me_price) |me_price| {
try appendStyledReturnsTable(arena, &lines, me_price, if (has_total) app.trailing_me_total else null, th); try appendStyledReturnsTable(arena, &lines, me_price, if (has_total) app.symbol_data.trailing_me_total else null, th);
} }
if (!has_total) { 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() }); 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 = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " Risk Metrics (monthly returns):", .style = th.headerStyle() }); 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() }); 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() });

View file

@ -8,6 +8,7 @@ const cli = @import("../commands/common.zig");
const theme = @import("theme.zig"); const theme = @import("theme.zig");
const tui = @import("../tui.zig"); const tui = @import("../tui.zig");
const projections_tab = @import("projections_tab.zig"); const projections_tab = @import("projections_tab.zig");
const analysis_tab = @import("analysis_tab.zig");
const App = tui.App; const App = tui.App;
const StyledLine = tui.StyledLine; 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 /// On refresh, fetches live via svc.loadPrices. Tab switching skips this
/// entirely because the portfolio_loaded guard in loadTabData() short-circuits. /// entirely because the portfolio_loaded guard in loadTabData() short-circuits.
pub fn loadPortfolioData(app: *App) void { pub fn loadPortfolioData(app: *App) void {
app.portfolio_loaded = true; app.portfolio.loaded = true;
app.freePortfolioSummary(); app.freePortfolioSummary();
const pf = app.portfolio orelse return; const pf = app.portfolio.file orelse return;
const positions = pf.positions(app.today, app.allocator) catch { const positions = pf.positions(app.today, app.allocator) catch {
app.setStatus("Error computing positions"); app.setStatus("Error computing positions");
@ -90,10 +91,10 @@ pub fn loadPortfolioData(app: *App) void {
} }
// Extract watchlist prices // Extract watchlist prices
if (app.watchlist_prices) |*wp| wp.clearRetainingCapacity() else { if (app.portfolio.watchlist_prices) |*wp| wp.clearRetainingCapacity() else {
app.watchlist_prices = std.StringHashMap(f64).init(app.allocator); 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(); var pp_iter = pp.iterator();
while (pp_iter.next()) |entry| { while (pp_iter.next()) |entry| {
if (!prices.contains(entry.key_ptr.*)) { if (!prices.contains(entry.key_ptr.*)) {
@ -105,10 +106,10 @@ pub fn loadPortfolioData(app: *App) void {
app.prefetched_prices = null; app.prefetched_prices = null;
} else { } else {
// Live fetch (refresh path) — fetch watchlist first, then stock prices // Live fetch (refresh path) — fetch watchlist first, then stock prices
if (app.watchlist_prices) |*wp| wp.clearRetainingCapacity() else { if (app.portfolio.watchlist_prices) |*wp| wp.clearRetainingCapacity() else {
app.watchlist_prices = std.StringHashMap(f64).init(app.allocator); 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| { if (app.watchlist) |wl| {
for (wl) |sym| { for (wl) |sym| {
const result = app.svc.getCandles(sym) catch continue; const result = app.svc.getCandles(sym) catch continue;
@ -167,7 +168,7 @@ pub fn loadPortfolioData(app: *App) void {
fetch_count = load_result.fetched_count; fetch_count = load_result.fetched_count;
stale_count = load_result.stale_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 // 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) { 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 // Transfer ownership: summary stored on App, candle_map freed after snapshots extracted
app.portfolio_summary = pf_data.summary; app.portfolio.summary = pf_data.summary;
app.historical_snapshots = pf_data.snapshots; app.portfolio.historical_snapshots = pf_data.snapshots;
{ {
// Free candle_map values and map (snapshots are value types, already copied) // Free candle_map values and map (snapshots are value types, already copied)
var it = pf_data.candle_map.valueIterator(); var it = pf_data.candle_map.valueIterator();
@ -251,7 +252,7 @@ pub fn loadPortfolioData(app: *App) void {
} }
pub fn sortPortfolioAllocations(app: *App) void { pub fn sortPortfolioAllocations(app: *App) void {
if (app.portfolio_summary) |s| { if (app.portfolio.summary) |s| {
const SortCtx = struct { const SortCtx = struct {
field: PortfolioSortField, field: PortfolioSortField,
dir: tui.SortDirection, dir: tui.SortDirection,
@ -279,7 +280,7 @@ pub fn rebuildPortfolioRows(app: *App) void {
app.portfolio_rows.clearRetainingCapacity(); app.portfolio_rows.clearRetainingCapacity();
app.freePreparedSections(); app.freePreparedSections();
if (app.portfolio_summary) |s| { if (app.portfolio.summary) |s| {
for (s.allocations, 0..) |a, i| { for (s.allocations, 0..) |a, i| {
// Skip allocations that don't match account filter // Skip allocations that don't match account filter
if (!allocationMatchesFilter(app, a)) continue; if (!allocationMatchesFilter(app, a)) continue;
@ -293,7 +294,7 @@ pub fn rebuildPortfolioRows(app: *App) void {
} }
} }
} else if (app.account_filter == null) { } else if (app.account_filter == null) {
if (app.portfolio) |pf| { if (app.portfolio.file) |pf| {
for (pf.lots) |lot| { for (pf.lots) |lot| {
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) { if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
lcount += 1; lcount += 1;
@ -311,7 +312,7 @@ pub fn rebuildPortfolioRows(app: *App) void {
// Only expand if multi-lot // Only expand if multi-lot
if (lcount > 1 and i < app.expanded.len and app.expanded[i]) { 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) // Collect matching lots, sort: open first (date desc), then closed (date desc)
var matching: std.ArrayList(zfin.Lot) = .empty; var matching: std.ArrayList(zfin.Lot) = .empty;
defer matching.deinit(app.allocator); defer matching.deinit(app.allocator);
@ -398,14 +399,14 @@ pub fn rebuildPortfolioRows(app: *App) void {
defer watch_seen.deinit(); defer watch_seen.deinit();
// Mark all portfolio position symbols as seen // Mark all portfolio position symbols as seen
if (app.portfolio_summary) |s| { if (app.portfolio.summary) |s| {
for (s.allocations) |a| { for (s.allocations) |a| {
watch_seen.put(a.symbol, {}) catch {}; watch_seen.put(a.symbol, {}) catch {};
} }
} }
// Watch lots from portfolio file // Watch lots from portfolio file
if (app.portfolio) |pf| { if (app.portfolio.file) |pf| {
for (pf.lots) |lot| { for (pf.lots) |lot| {
if (lot.security_type == .watch) { if (lot.security_type == .watch) {
if (watch_seen.contains(lot.priceSymbol())) continue; 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) // 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; app.prepared_options = views.Options.init(app.today, app.allocator, pf.lots, app.account_filter) catch null;
if (app.prepared_options) |opts| { if (app.prepared_options) |opts| {
if (opts.items.len > 0) { if (opts.items.len > 0) {
@ -557,7 +558,7 @@ pub fn buildAccountList(app: *App) void {
app.account_numbers.clearRetainingCapacity(); app.account_numbers.clearRetainingCapacity();
app.account_shortcut_keys.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 // Collect distinct account names from portfolio lots
var seen = std.StringHashMap(void).init(app.allocator); var seen = std.StringHashMap(void).init(app.allocator);
@ -578,7 +579,7 @@ pub fn buildAccountList(app: *App) void {
app.ensureAccountMap(); app.ensureAccountMap();
// Phase 1: add accounts in accounts.srf order (if available) // 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| { for (am.entries) |entry| {
if (seen.contains(entry.account)) { if (seen.contains(entry.account)) {
app.account_list.append(app.allocator, entry.account) catch continue; 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); if (app.filtered_positions) |fp| app.allocator.free(fp);
app.filtered_positions = null; app.filtered_positions = null;
const filter = app.account_filter orelse return; 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; 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 }; const af = app.account_filter orelse return .{ .value = 0, .cost = 0 };
var value: f64 = 0; var value: f64 = 0;
var cost: f64 = 0; var cost: f64 = 0;
if (app.portfolio_summary) |s| { if (app.portfolio.summary) |s| {
for (s.allocations) |a| { for (s.allocations) |a| {
if (allocationMatchesFilter(app, a)) { if (allocationMatchesFilter(app, a)) {
const fa = filteredAllocValues(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); const ns = pf.nonStockValueForAccount(app.today, af);
value += ns; value += ns;
cost += 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 { pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
const th = app.theme; 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); try drawWelcomeScreen(app, arena, buf, width, height);
return; return;
} }
@ -774,7 +775,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
var lines: std.ArrayList(StyledLine) = .empty; var lines: std.ArrayList(StyledLine) = .empty;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
if (app.portfolio_summary) |s| { if (app.portfolio.summary) |s| {
if (app.account_filter) |af| { if (app.account_filter) |af| {
// Filtered mode: compute account-specific totals // Filtered mode: compute account-specific totals
const ft = computeFilteredTotals(app); 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(); const summary_style = if (filtered_gl >= 0) th.positiveStyle() else th.negativeStyle();
try lines.append(arena, .{ .text = summary_text, .style = summary_style }); 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}); const asof_text = try std.fmt.allocPrint(arena, " (as of close on {f})", .{d});
try lines.append(arena, .{ .text = asof_text, .style = th.mutedStyle() }); 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 }); try lines.append(arena, .{ .text = summary_text, .style = summary_style });
// "as of" date indicator // "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}); const asof_text = try std.fmt.allocPrint(arena, " (as of close on {f})", .{d});
try lines.append(arena, .{ .text = asof_text, .style = th.mutedStyle() }); try lines.append(arena, .{ .text = asof_text, .style = th.mutedStyle() });
} }
// Net Worth line (only if portfolio has illiquid assets) // Net Worth line (only if portfolio has illiquid assets)
if (app.portfolio) |pf| { if (app.portfolio.file) |pf| {
if (pf.hasType(.illiquid)) { if (pf.hasType(.illiquid)) {
const illiquid_total = pf.totalIlliquid(app.today); const illiquid_total = pf.totalIlliquid(app.today);
const net_worth = zfin.valuation.netWorth(app.today, pf, s); 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 // Historical portfolio value snapshots
if (app.historical_snapshots) |snapshots| { if (app.portfolio.historical_snapshots) |snapshots| {
try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
var hist_parts: [6][]const u8 = undefined; var hist_parts: [6][]const u8 = undefined;
for (zfin.valuation.HistoricalPeriod.all, 0..) |period, pi| { 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() }); 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 <SYMBOL>' for each holding.", .style = th.mutedStyle() }); try lines.append(arena, .{ .text = " No cached prices. Run 'zfin perf <SYMBOL>' for each holding.", .style = th.mutedStyle() });
} else { } else {
try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); 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); const is_active_sym = std.mem.eql(u8, row.symbol, app.symbol);
switch (row.kind) { switch (row.kind) {
.position => { .position => {
if (app.portfolio_summary) |s| { if (app.portfolio.summary) |s| {
if (row.pos_idx < s.allocations.len) { if (row.pos_idx < s.allocations.len) {
const a = s.allocations[row.pos_idx]; const a = s.allocations[row.pos_idx];
// Use account-filtered values for multi-account positions // 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 date_col: []const u8 = "";
var acct_col: []const u8 = ""; var acct_col: []const u8 = "";
if (!is_multi) { if (!is_multi) {
if (app.portfolio) |pf| { if (app.portfolio.file) |pf| {
for (pf.lots) |lot| { for (pf.lots) |lot| {
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) { if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
if (matchesAccountFilter(app, lot.account)) { 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_gl_str: []const u8 = "";
var lot_mv_str: []const u8 = ""; var lot_mv_str: []const u8 = "";
var lot_positive = true; var lot_positive = true;
if (app.portfolio_summary) |s| { if (app.portfolio.summary) |s| {
if (row.pos_idx < s.allocations.len) { if (row.pos_idx < s.allocations.len) {
const price = s.allocations[row.pos_idx].current_price; const price = s.allocations[row.pos_idx].current_price;
const use_price = lot.close_price orelse 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 => { .watchlist => {
var price_str3: [16]u8 = undefined; 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 "--") (if (wp.get(row.symbol)) |p| (std.fmt.bufPrint(&price_str3, "{f}", .{Money.from(p)}) catch "$?") else "--")
else else
"--"; "--";
@ -1074,7 +1075,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
} }
}, },
.cash_total => { .cash_total => {
if (app.portfolio) |pf| { if (app.portfolio.file) |pf| {
const total_cash = pf.totalCash(app.today); const total_cash = pf.totalCash(app.today);
const arrow3: []const u8 = if (app.cash_expanded) "v " else "> "; const arrow3: []const u8 = if (app.cash_expanded) "v " else "> ";
const text = try std.fmt.allocPrint(arena, " {s}Total Cash {f}", .{ 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 => { .illiquid_total => {
if (app.portfolio) |pf| { if (app.portfolio.file) |pf| {
const total_illiquid = pf.totalIlliquid(app.today); const total_illiquid = pf.totalIlliquid(app.today);
const arrow4: []const u8 = if (app.illiquid_expanded) "v " else "> "; const arrow4: []const u8 = if (app.illiquid_expanded) "v " else "> ";
const text = try std.fmt.allocPrint(arena, " {s}Total Illiquid {f}", .{ 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(); app.account_list.clearRetainingCapacity();
// Re-read the portfolio file // Re-read the portfolio file
if (app.portfolio) |*pf| pf.deinit(); if (app.portfolio.file) |*pf| pf.deinit();
app.portfolio = null; app.portfolio.file = null;
if (app.portfolio_path) |path| { if (app.portfolio_path) |path| {
const file_data = std.Io.Dir.cwd().readFileAlloc(app.io, path, app.allocator, .limited(10 * 1024 * 1024)) catch { const file_data = std.Io.Dir.cwd().readFileAlloc(app.io, path, app.allocator, .limited(10 * 1024 * 1024)) catch {
app.setStatus("Error reading portfolio file"); app.setStatus("Error reading portfolio file");
@ -1194,7 +1195,7 @@ pub fn reloadPortfolioFile(app: *App) void {
}; };
defer app.allocator.free(file_data); defer app.allocator.free(file_data);
if (zfin.cache.deserializePortfolio(app.allocator, file_data)) |pf| { if (zfin.cache.deserializePortfolio(app.allocator, file_data)) |pf| {
app.portfolio = pf; app.portfolio.file = pf;
} else |_| { } else |_| {
app.setStatus("Error parsing portfolio file"); app.setStatus("Error parsing portfolio file");
return; return;
@ -1220,7 +1221,7 @@ pub fn reloadPortfolioFile(app: *App) void {
app.scroll_offset = 0; app.scroll_offset = 0;
app.portfolio_rows.clearRetainingCapacity(); 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 { const positions = pf.positions(app.today, app.allocator) catch {
app.setStatus("Error computing positions"); app.setStatus("Error computing positions");
return; return;
@ -1252,7 +1253,7 @@ pub fn reloadPortfolioFile(app: *App) void {
missing += 1; 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 // 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) { 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; return;
}, },
}; };
app.portfolio_summary = pf_data.summary; app.portfolio.summary = pf_data.summary;
app.historical_snapshots = pf_data.snapshots; app.portfolio.historical_snapshots = pf_data.snapshots;
{ {
var it = pf_data.candle_map.valueIterator(); var it = pf_data.candle_map.valueIterator();
while (it.next()) |v| app.allocator.free(v.*); while (it.next()) |v| app.allocator.free(v.*);
@ -1283,15 +1284,17 @@ pub fn reloadPortfolioFile(app: *App) void {
rebuildPortfolioRows(app); rebuildPortfolioRows(app);
// Invalidate analysis data -- it holds pointers into old portfolio memory // Invalidate analysis data -- it holds pointers into old portfolio memory
if (app.analysis_result) |*ar| ar.deinit(app.allocator); if (app.states.analysis.result) |*ar| ar.deinit(app.allocator);
app.analysis_result = null; app.states.analysis.result = null;
app.analysis_loaded = false; app.states.analysis.loaded = false;
app.analysis_disabled = false; // Portfolio loaded; analysis is now possible // 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 // If currently on the analysis tab, eagerly recompute so the user
// doesn't see an error message before switching away and back. // doesn't see an error message before switching away and back.
if (app.active_tab == .analysis) { if (app.active_tab == .analysis) {
app.loadAnalysisData(); analysis_tab.tab.activate(&app.states.analysis, app) catch {};
} }
// Invalidate projections data — projections.srf may have changed // Invalidate projections data — projections.srf may have changed

View file

@ -52,7 +52,7 @@ pub fn loadData(app: *App) void {
const portfolio_dir = portfolio_path[0..dir_end]; const portfolio_dir = portfolio_path[0..dir_end];
// As-of mode — load historical snapshot + ctx. This path is // 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 // the snapshot's own totals and lot composition are the source of
// truth for the projection. // truth for the projection.
// //
@ -113,14 +113,14 @@ pub fn loadData(app: *App) void {
// Imported-only as-of: scale today's allocations to // Imported-only as-of: scale today's allocations to
// the imported liquid total. Requires the live // the imported liquid total. Requires the live
// portfolio summary, which the portfolio tab loads // portfolio summary, which the portfolio tab loads
// up-front into `app.portfolio_summary`. // up-front into `app.portfolio.summary`.
const summary = app.portfolio_summary orelse { const summary = app.portfolio.summary orelse {
app.setStatus("Imported as-of needs live portfolio — visit Portfolio tab first"); app.setStatus("Imported as-of needs live portfolio — visit Portfolio tab first");
app.projections_as_of = null; app.projections_as_of = null;
app.projections_as_of_requested = null; app.projections_as_of_requested = null;
break :as_of; 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.setStatus("Imported as-of needs live portfolio — visit Portfolio tab first");
app.projections_as_of = null; app.projections_as_of = null;
app.projections_as_of_requested = 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 // Live path. Reached either because no as-of was requested OR the
// as-of branch above bailed and fell through after clearing state. // 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"); app.setStatus("No portfolio summary — visit Portfolio tab first");
return; return;
}; };
const portfolio = app.portfolio orelse return; const portfolio = app.portfolio.file orelse return;
const ctx = view.loadProjectionContext( const ctx = view.loadProjectionContext(
app.io, app.io,
@ -267,7 +267,7 @@ pub fn drawContent(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, wi
const arena = ctx.arena; const arena = ctx.arena;
// Determine whether to use Kitty graphics // Determine whether to use Kitty graphics
const use_kitty = switch (app.chart.config.mode) { const use_kitty = switch (app.chart_config.mode) {
.braille => false, .braille => false,
.kitty => true, .kitty => true,
.auto => if (app.vx_app) |va| va.vx.caps.kitty_graphics else false, .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; const px_h: u32 = @as(u32, chart_rows) * cell_h;
if (px_w < 100 or px_h < 100) return; if (px_w < 100 or px_h < 100) return;
const capped_w = @min(px_w, app.chart.config.max_width); const capped_w = @min(px_w, app.chart_config.max_width);
const capped_h = @min(px_h, app.chart.config.max_height); const capped_h = @min(px_h, app.chart_config.max_height);
// Render or reuse cached image // Render or reuse cached image
if (app.projections_chart_dirty) { if (app.projections_chart_dirty) {

View file

@ -6,11 +6,164 @@ const Money = @import("../Money.zig");
const theme = @import("theme.zig"); const theme = @import("theme.zig");
const chart = @import("chart.zig"); const chart = @import("chart.zig");
const tui = @import("../tui.zig"); const tui = @import("../tui.zig");
const framework = @import("tab_framework.zig");
const App = tui.App; const App = tui.App;
const StyledLine = tui.StyledLine; const StyledLine = tui.StyledLine;
const glyph = tui.glyph; 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 ───────────────────────────────────────────────── // ── Rendering ─────────────────────────────────────────────────
/// Draw the quote tab content. Uses Kitty graphics for the chart when available, /// 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; const arena = ctx.arena;
// Determine whether to use Kitty graphics // Determine whether to use Kitty graphics
const use_kitty = switch (app.chart.config.mode) { const use_kitty = switch (app.chart_config.mode) {
.braille => false, .braille => false,
.kitty => true, .kitty => true,
.auto => if (app.vx_app) |va| va.vx.caps.kitty_graphics else false, .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 { drawWithKittyChart(app, ctx, buf, width, height) catch {
// On any failure, fall back to braille // On any failure, fall back to braille
try app.drawStyledContent(arena, buf, width, height, try buildStyledLines(app, arena)); 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 { fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void {
const arena = ctx.arena; const arena = ctx.arena;
const th = app.theme; 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 // Build text header (symbol, price, change) — first few lines
var lines: std.ArrayList(StyledLine) = .empty; var lines: std.ArrayList(StyledLine) = .empty;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// Symbol + price header // 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 }); 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() }); try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() });
if (q.previous_close > 0) { 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" }; const timeframes = [_]chart.Timeframe{ .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" };
for (timeframes) |tf| { for (timeframes) |tf| {
const lbl = tf.label(); const lbl = tf.label();
if (tf == app.chart.timeframe) { if (tf == app.states.quote.chart.timeframe) {
tf_buf[tf_pos] = '['; tf_buf[tf_pos] = '[';
tf_pos += 1; tf_pos += 1;
@memcpy(tf_buf[tf_pos..][0..lbl.len], lbl); @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)"; const hint = " ([ ] to change)";
@memcpy(tf_buf[tf_pos..][0..hint.len], hint); @memcpy(tf_buf[tf_pos..][0..hint.len], hint);
tf_pos += hint.len; 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() }); 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; if (px_w < 100 or px_h < 100) return;
// Apply resolution cap from chart config // Apply resolution cap from chart config
const capped_w = @min(px_w, app.chart.config.max_width); const capped_w = @min(px_w, app.chart_config.max_width);
const capped_h = @min(px_h, app.chart.config.max_height); const capped_h = @min(px_h, app.chart_config.max_height);
// Check if we need to re-render the chart image // Check if we need to re-render the chart image
const symbol_changed = app.chart.symbol_len != app.symbol.len or const symbol_changed = app.states.quote.chart.symbol_len != app.symbol.len or
!std.mem.eql(u8, app.chart.symbol[0..app.chart.symbol_len], app.symbol); !std.mem.eql(u8, app.states.quote.chart.symbol[0..app.states.quote.chart.symbol_len], app.symbol);
const tf_changed = app.chart.timeframe_rendered == null or app.chart.timeframe_rendered.? != app.chart.timeframe; 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 // Free old image
if (app.chart.image_id) |old_id| { if (app.states.quote.chart.image_id) |old_id| {
if (app.vx_app) |va| { if (app.vx_app) |va| {
va.vx.freeImage(va.tty.writer(), old_id); 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, invalidate the indicator cache
if (symbol_changed) { if (symbol_changed) {
app.chart.freeCache(app.allocator); app.states.quote.chart.freeCache(app.allocator);
} }
// Check if we can reuse cached indicators // 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 is invalid, compute new indicators
if (!cache_valid) { if (!cache_valid) {
// Free old cache if it exists // Free old cache if it exists
app.chart.freeCache(app.allocator); app.states.quote.chart.freeCache(app.allocator);
// Compute and cache new indicators // Compute and cache new indicators
const new_cache = chart.computeIndicators( const new_cache = chart.computeIndicators(
app.allocator, app.allocator,
c, c,
app.chart.timeframe, app.states.quote.chart.timeframe,
) catch |err| { ) catch |err| {
app.chart.dirty = false; app.states.quote.chart.dirty = false;
var err_buf: [128]u8 = undefined; var err_buf: [128]u8 = undefined;
const msg = std.fmt.bufPrint(&err_buf, "Indicator computation failed: {s}", .{@errorName(err)}) catch "Indicator computation failed"; const msg = std.fmt.bufPrint(&err_buf, "Indicator computation failed: {s}", .{@errorName(err)}) catch "Indicator computation failed";
app.setStatus(msg); app.setStatus(msg);
return; return;
}; };
app.chart.cached_indicators = new_cache; app.states.quote.chart.cached_indicators = new_cache;
// Update cache metadata // 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 n = @min(c.len, max_days);
const data = c[c.len - n ..]; const data = c[c.len - n ..];
app.chart.cache_candle_count = data.len; app.states.quote.chart.cache_candle_count = data.len;
app.chart.cache_timeframe = app.chart.timeframe; app.states.quote.chart.cache_timeframe = app.states.quote.chart.timeframe;
app.chart.cache_last_close = if (data.len > 0) data[data.len - 1].close else 0; 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, // Render and transmit — use the app's main allocator, NOT the arena,
// because z2d allocates large pixel buffers that would bloat the arena. // because z2d allocates large pixel buffers that would bloat the arena.
if (app.vx_app) |va| { if (app.vx_app) |va| {
// Pass cached indicators to avoid recomputation during rendering // 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( const chart_result = chart.renderChart(
app.io, app.io,
app.allocator, app.allocator,
c, c,
app.chart.timeframe, app.states.quote.chart.timeframe,
capped_w, capped_w,
capped_h, capped_h,
th, th,
cached_ptr, cached_ptr,
) catch |err| { ) catch |err| {
app.chart.dirty = false; app.states.quote.chart.dirty = false;
var err_buf: [128]u8 = undefined; var err_buf: [128]u8 = undefined;
const msg = std.fmt.bufPrint(&err_buf, "Chart render failed: {s}", .{@errorName(err)}) catch "Chart render failed"; const msg = std.fmt.bufPrint(&err_buf, "Chart render failed: {s}", .{@errorName(err)}) catch "Chart render failed";
app.setStatus(msg); 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. // This avoids the PNG encode → file write → file read → PNG decode roundtrip.
const base64_enc = std.base64.standard.Encoder; const base64_enc = std.base64.standard.Encoder;
const b64_buf = app.allocator.alloc(u8, base64_enc.calcSize(chart_result.rgb_data.len)) catch { 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"); app.setStatus("Chart: base64 alloc failed");
return; return;
}; };
@ -225,31 +378,31 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
chart_result.height, chart_result.height,
.rgb, .rgb,
) catch |err| { ) catch |err| {
app.chart.dirty = false; app.states.quote.chart.dirty = false;
var err_buf: [128]u8 = undefined; var err_buf: [128]u8 = undefined;
const msg = std.fmt.bufPrint(&err_buf, "Image transmit failed: {s}", .{@errorName(err)}) catch "Image transmit failed"; const msg = std.fmt.bufPrint(&err_buf, "Image transmit failed: {s}", .{@errorName(err)}) catch "Image transmit failed";
app.setStatus(msg); app.setStatus(msg);
return; return;
}; };
app.chart.image_id = img.id; app.states.quote.chart.image_id = img.id;
app.chart.image_width = @intCast(chart_cols); app.states.quote.chart.image_width = @intCast(chart_cols);
app.chart.image_height = chart_rows; app.states.quote.chart.image_height = chart_rows;
// Track what we rendered // Track what we rendered
const sym_len = @min(app.symbol.len, 16); const sym_len = @min(app.symbol.len, 16);
@memcpy(app.chart.symbol[0..sym_len], app.symbol[0..sym_len]); @memcpy(app.states.quote.chart.symbol[0..sym_len], app.symbol[0..sym_len]);
app.chart.symbol_len = sym_len; app.states.quote.chart.symbol_len = sym_len;
app.chart.timeframe_rendered = app.chart.timeframe; app.states.quote.chart.timeframe_rendered = app.states.quote.chart.timeframe;
app.chart.price_min = chart_result.price_min; app.states.quote.chart.price_min = chart_result.price_min;
app.chart.price_max = chart_result.price_max; app.states.quote.chart.price_max = chart_result.price_max;
app.chart.rsi_latest = chart_result.rsi_latest; app.states.quote.chart.rsi_latest = chart_result.rsi_latest;
app.chart.dirty = false; app.states.quote.chart.dirty = false;
} }
} }
// Place the image in the cell buffer // 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 // Place image at the first cell of the chart area
const chart_row_start: usize = header_rows; const chart_row_start: usize = header_rows;
const chart_col_start: usize = 1; // 1 col left margin 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, .img_id = img_id,
.options = .{ .options = .{
.size = .{ .size = .{
.rows = app.chart.image_height, .rows = app.states.quote.chart.image_height,
.cols = app.chart.image_width, .cols = app.states.quote.chart.image_width,
}, },
.scale = .contain, .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) ─────────── // ── Axis labels (terminal text in the right margin) ───────────
// The chart image uses layout fractions: price=72%, gap=8%, RSI=20% // The chart image uses layout fractions: price=72%, gap=8%, RSI=20%
// Map these to terminal rows to position labels. // Map these to terminal rows to position labels.
const img_rows = app.chart.image_height; const img_rows = app.states.quote.chart.image_height;
const label_col: usize = @as(usize, chart_col_start) + @as(usize, app.chart.image_width) + 1; const label_col: usize = @as(usize, chart_col_start) + @as(usize, app.states.quote.chart.image_width) + 1;
const label_style = th.mutedStyle(); 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%) // Price axis labels — evenly spaced across the price panel (top 72%)
const price_panel_rows = @as(f64, @floatFromInt(img_rows)) * 0.72; const price_panel_rows = @as(f64, @floatFromInt(img_rows)) * 0.72;
const n_price_labels: usize = 5; const n_price_labels: usize = 5;
for (0..n_price_labels) |i| { for (0..n_price_labels) |i| {
const frac = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n_price_labels - 1)); 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_f = @as(f64, @floatFromInt(chart_row_start)) + frac * price_panel_rows;
const row: usize = @intFromFloat(@round(row_f)); const row: usize = @intFromFloat(@round(row_f));
if (row >= height) continue; 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 // 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) { if (detail_start_row + 8 < height) {
var detail_lines: std.ArrayList(StyledLine) = .empty; var detail_lines: std.ArrayList(StyledLine) = .empty;
try detail_lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try detail_lines.append(arena, .{ .text = "", .style = th.contentStyle() });
const latest = c[c.len - 1]; 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 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); 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; var ago_buf: [16]u8 = undefined;
if (app.quote != null and app.quote_timestamp > 0) { if (app.states.quote.live != null and app.states.quote.timestamp > 0) {
// wall-clock required: per-frame "now" for the "refreshed Xs ago" // wall-clock required: per-frame "now" for the data-age readout.
// readout on the live quote header.
const now_s = std.Io.Timestamp.now(app.io, .real).toSeconds(); 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() }); 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() }); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s} (as of close on {f})", .{ app.symbol, d }), .style = th.headerStyle() });
} else { } else {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s}", .{app.symbol}), .style = th.headerStyle() }); 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() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// Use stored real-time quote if available (fetched on manual refresh) // 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| { if (quote_data) |q| {
// No candle data but have a quote - show it // 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() }); 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 var col4 = Column.init(); // Top holdings
col4.width = 30; col4.width = 30;
if (app.etf_profile) |profile| { if (app.symbol_data.etf_profile) |profile| {
// Col 2: ETF key stats // Col 2: ETF key stats
try col2.add(arena, "ETF Profile", th.headerStyle()); try col2.add(arena, "ETF Profile", th.headerStyle());
if (profile.expense_ratio) |er| { if (profile.expense_ratio) |er| {

View file

@ -47,6 +47,30 @@
//! pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool { ... } //! pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool { ... }
//! pub fn handlePaste(state: *State, app: *App, text: []const u8) 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) ───────────────────────────────────── //! // ── Misc (required) ─────────────────────────────────────
//! pub fn isDisabled(app: *App) bool { ... } //! pub fn isDisabled(app: *App) bool { ... }
//! }; //! };
@ -56,8 +80,9 @@
//! empty `default_bindings` / `action_labels` / `status_hints`. //! empty `default_bindings` / `action_labels` / `status_hints`.
//! No implicit defaults — the contract is fully explicit for //! No implicit defaults — the contract is fully explicit for
//! action-related fields and lifecycle hooks. The event hooks //! action-related fields and lifecycle hooks. The event hooks
//! (`handleKey`, `handleMouse`, `handlePaste`) are the exception: //! (`handleKey`, `handleMouse`, `handlePaste`) and context-change
//! their absence means "this tab doesn't process that event class." //! 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 //! Lifecycle hooks that aren't meaningful for a given tab can use
//! the `noop*` factory helpers below to inherit no-op //! 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) ───────────────── // ── Lifecycle hook factories (no-op defaults) ─────────────────
// //
// Tabs that don't need a particular lifecycle hook can declare // 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 // This keeps the contract explicit (every required field is named
// in the tab struct) while letting tabs avoid writing dummy bodies. // in the tab struct) while letting tabs avoid writing dummy bodies.
// //
// Event hooks (handleKey, handleMouse, handlePaste) are NOT in // Event hooks (handleKey, handleMouse, handlePaste) and context-
// this list — they're optional via `@hasDecl` checking, so a tab // change hooks (onSymbolChange) are NOT in this list — they're
// that doesn't care simply omits the method. // optional via `@hasDecl` checking, so a tab that doesn't care
// simply omits the method.
const App = @import("../tui.zig").App; 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 { ... }", "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 { ... }",
);
}
} }
} }