zfin/src/tui.zig
2026-05-15 08:54:50 -07:00

2443 lines
107 KiB
Zig

const std = @import("std");
const vaxis = @import("vaxis");
const zfin = @import("root.zig");
const fmt = @import("format.zig");
const views = @import("views/portfolio_sections.zig");
const cli = @import("commands/common.zig");
const keybinds = @import("tui/keybinds.zig");
const tab_framework = @import("tui/tab_framework.zig");
// Touch tab_framework so its tests are reachable via the import
// graph. The framework is otherwise unused at this point in the
// migration; will be properly wired in step 3.
comptime {
_ = tab_framework;
}
const theme = @import("tui/theme.zig");
const chart = @import("tui/chart.zig");
/// Single source of truth for tab modules. Each entry is the
/// imported tab module; the field name is the tab's tag (must match
/// the `Tab` enum variant). `TabStates` is derived from this
/// registry at comptime — adding a new tab is a single edit here
/// (plus the matching `Tab` enum variant + label, until those are
/// derived too).
const tab_modules = .{
.portfolio = @import("tui/portfolio_tab.zig"),
.quote = @import("tui/quote_tab.zig"),
.performance = @import("tui/performance_tab.zig"),
.options = @import("tui/options_tab.zig"),
.earnings = @import("tui/earnings_tab.zig"),
.analysis = @import("tui/analysis_tab.zig"),
.history = @import("tui/history_tab.zig"),
.projections = @import("tui/projections_tab.zig"),
};
/// Comptime-generated table of single-character grapheme slices with static lifetime.
/// This avoids dangling pointers from stack-allocated temporaries in draw functions.
const ascii_g = blk: {
var table: [128][]const u8 = undefined;
for (0..128) |i| {
const ch: [1]u8 = .{@as(u8, @intCast(i))};
table[i] = &ch;
}
break :blk table;
};
/// Build a fixed-display-width column header label with optional sort indicator.
/// The indicator (▲/▼, 3 bytes, 1 display column) replaces a padding space so total
/// display width stays constant. Indicator always appears on the left side.
/// `left` controls text alignment (left-aligned vs right-aligned).
pub fn colLabel(buf: []u8, name: []const u8, comptime col_width: usize, left: bool, indicator: ?[]const u8) []const u8 {
const ind = indicator orelse {
// No indicator: plain padded label
if (left) {
@memset(buf[0..col_width], ' ');
@memcpy(buf[0..name.len], name);
return buf[0..col_width];
} else {
@memset(buf[0..col_width], ' ');
const offset = col_width - name.len;
@memcpy(buf[offset..][0..name.len], name);
return buf[0..col_width];
}
};
// Indicator always on the left, replacing one padding space.
// total display cols = col_width, byte length = col_width - 1 + ind.len
const total_bytes = col_width - 1 + ind.len;
if (total_bytes > buf.len) return name;
if (left) {
// "▲Name " — indicator, text, then spaces
@memcpy(buf[0..ind.len], ind);
@memcpy(buf[ind.len..][0..name.len], name);
const content_len = ind.len + name.len;
if (content_len < total_bytes) @memset(buf[content_len..total_bytes], ' ');
} else {
// " ▲Name" — spaces, indicator, then text
const pad = col_width - name.len - 1;
@memset(buf[0..pad], ' ');
@memcpy(buf[pad..][0..ind.len], ind);
@memcpy(buf[pad + ind.len ..][0..name.len], name);
}
return buf[0..total_bytes];
}
pub fn glyph(ch: u8) []const u8 {
if (ch < 128) return ascii_g[ch];
return " ";
}
/// Tab enum derived from `tab_modules` registry. Each variant
/// matches a registry field name; variant order = registry order
/// = tab-bar display order. Adding a tab requires no edit here —
/// just append to `tab_modules` and the variant appears.
pub const Tab = blk: {
const reg_fields = std.meta.fields(@TypeOf(tab_modules));
var names: [reg_fields.len][]const u8 = undefined;
var values: [reg_fields.len]u8 = undefined;
for (reg_fields, 0..) |f, i| {
names[i] = f.name;
values[i] = @intCast(i);
}
break :blk @Enum(u8, .exhaustive, &names, &values);
};
/// Comptime lookup table of tab-bar display labels, indexed by
/// `@intFromEnum(tab)`. Each entry is `" {N}:{label} "` composed
/// from the 1-indexed registry position + the tab module's
/// `pub const label`. The format (number prefix, padding) is
/// framework policy; the bare name is owned by the tab module.
const tab_labels = blk: {
const reg_fields = std.meta.fields(@TypeOf(tab_modules));
var arr: [reg_fields.len][]const u8 = undefined;
for (reg_fields, 0..) |f, i| {
const Module = @field(tab_modules, f.name);
arr[i] = std.fmt.comptimePrint(" {d}:{s} ", .{ i + 1, Module.tab.label });
}
break :blk arr;
};
fn tabLabel(t: Tab) []const u8 {
return tab_labels[@intFromEnum(t)];
}
/// All tab variants in registry order. Used for tab-bar iteration
/// (rendering, hit-testing, next/prev navigation). Equivalent to
/// `std.enums.values(Tab)`; aliased for brevity at call sites.
const tabs: []const Tab = std.enums.values(Tab);
pub const InputMode = enum {
normal,
symbol_input,
help,
account_picker,
account_search,
/// Mini popup on the projections tab for entering an as-of date.
/// Same input scaffolding as `symbol_input` (shared `input_buf`),
/// committed via `parseAsOfDate`.
date_input,
};
pub const StyledLine = struct {
text: []const u8,
style: vaxis.Style,
// Optional per-character style override ranges (for mixed-color lines)
alt_text: ?[]const u8 = null, // text for the gain/loss column
alt_style: ?vaxis.Style = null,
alt_start: usize = 0,
alt_end: usize = 0,
// Optional pre-encoded grapheme array for multi-byte Unicode (e.g. braille charts).
// When set, each element is a grapheme string for one column position.
graphemes: ?[]const []const u8 = null,
// Optional per-cell style array (same length as graphemes). Enables color gradients.
cell_styles: ?[]const vaxis.Style = null,
};
// ── Tab-specific types ───────────────────────────────────────────
// These logically belong to individual tab files, but live here because
// App's struct fields reference them and Zig requires field types to be
// resolved in the same struct definition.
pub const PortfolioSortField = enum {
symbol,
shares,
avg_cost,
price,
market_value,
gain_loss,
weight,
account,
pub fn label(self: PortfolioSortField) []const u8 {
return switch (self) {
.symbol => "Symbol",
.shares => "Shares",
.avg_cost => "Avg Cost",
.price => "Price",
.market_value => "Market Value",
.gain_loss => "Gain/Loss",
.weight => "Weight",
.account => "Account",
};
}
pub fn next(self: PortfolioSortField) ?PortfolioSortField {
const fields = std.meta.fields(PortfolioSortField);
const idx: usize = @intFromEnum(self);
if (idx + 1 >= fields.len) return null;
return @enumFromInt(idx + 1);
}
pub fn prev(self: PortfolioSortField) ?PortfolioSortField {
const idx: usize = @intFromEnum(self);
if (idx == 0) return null;
return @enumFromInt(idx - 1);
}
};
pub const SortDirection = enum {
asc,
desc,
pub fn flip(self: SortDirection) SortDirection {
return if (self == .asc) .desc else .asc;
}
pub fn indicator(self: SortDirection) []const u8 {
return if (self == .asc) "" else "";
}
};
pub const PortfolioRow = struct {
kind: Kind,
symbol: []const u8,
/// For position rows: index into allocations; for lot rows: lot data.
pos_idx: usize = 0,
lot: ?zfin.Lot = null,
/// Number of lots for this symbol (set on position rows)
lot_count: usize = 0,
/// DRIP summary data (for drip_summary rows)
drip_is_lt: bool = false, // true = LT summary, false = ST summary
drip_lot_count: usize = 0,
drip_shares: f64 = 0,
drip_avg_cost: f64 = 0,
drip_date_first: ?zfin.Date = null,
drip_date_last: ?zfin.Date = null,
/// Pre-formatted text from view model (options and CDs)
prepared_text: ?[]const u8 = null,
/// Semantic styles from view model
row_style: fmt.StyleIntent = .normal,
premium_style: fmt.StyleIntent = .normal,
/// Column offset for premium alt-style coloring (options only)
premium_col_start: usize = 0,
const Kind = enum { position, lot, watchlist, section_header, option_row, cd_row, cash_row, cash_total, illiquid_row, illiquid_total, drip_summary };
};
pub const OptionsRowKind = enum { expiration, calls_header, puts_header, call, put };
pub const OptionsRow = struct {
kind: OptionsRowKind,
exp_idx: usize = 0, // index into options_data chains
contract: ?zfin.OptionContract = null,
};
pub const ChartState = struct {
timeframe: chart.Timeframe = .@"1Y",
image_id: ?u32 = null, // currently transmitted Kitty image ID
image_width: u16 = 0, // image width in cells
image_height: u16 = 0, // image height in cells
symbol: [16]u8 = undefined, // symbol the chart was rendered for
symbol_len: usize = 0,
timeframe_rendered: ?chart.Timeframe = null, // timeframe the chart was rendered for
timeframe_row: ?usize = null, // screen row of the timeframe selector (for mouse clicks)
dirty: bool = true, // needs re-render
price_min: f64 = 0,
price_max: f64 = 0,
rsi_latest: ?f64 = null,
// Cached indicator data (persists across frames to avoid recomputation)
cached_indicators: ?chart.CachedIndicators = null,
cache_candle_count: usize = 0, // candle count when cache was computed
cache_timeframe: ?chart.Timeframe = null, // timeframe when cache was computed
cache_last_close: f64 = 0, // last candle's close when cache was computed
/// Free cached indicator memory.
pub fn freeCache(self: *ChartState, alloc: std.mem.Allocator) void {
if (self.cached_indicators) |*cache| {
cache.deinit(alloc);
self.cached_indicators = null;
}
self.cache_candle_count = 0;
self.cache_timeframe = null;
self.cache_last_close = 0;
}
/// Check if cache is valid for the given candle data and timeframe.
pub fn isCacheValid(self: *const ChartState, candles: []const zfin.Candle, timeframe: chart.Timeframe) bool {
if (self.cached_indicators == null) return false;
if (self.cache_timeframe == null or self.cache_timeframe.? != timeframe) return false;
// Slice candles to timeframe (same logic as renderChart)
const max_days = timeframe.tradingDays();
const n = @min(candles.len, max_days);
const data = candles[candles.len - n ..];
if (data.len != self.cache_candle_count) return false;
if (data.len == 0) return false;
// Check if last close changed (detects data refresh)
const last_close = data[data.len - 1].close;
if (@abs(last_close - self.cache_last_close) > 0.0001) return false;
return true;
}
};
/// Per-tab state, owned by `App` and accessed as `app.states.<tab>`.
///
/// Per-tab private state aggregator, derived at comptime from the
/// `tab_modules` registry. One field per registered tab; the field
/// name matches the registry tag (and the `Tab` enum variant), and
/// the type is that tab module's `State`.
///
/// Adding a tab is one edit: append it to `tab_modules` (and add the
/// matching `Tab` enum variant + `label`). `TabStates` updates
/// automatically.
pub const TabStates = blk: {
const reg_fields = std.meta.fields(@TypeOf(tab_modules));
var names: [reg_fields.len][]const u8 = undefined;
var types: [reg_fields.len]type = undefined;
var attrs: [reg_fields.len]std.builtin.Type.StructField.Attributes = undefined;
for (reg_fields, 0..) |f, i| {
const Module = @field(tab_modules, f.name);
const default: Module.State = .{};
names[i] = f.name;
types[i] = Module.State;
attrs[i] = .{ .default_value_ptr = &default };
}
break :blk @Struct(.auto, null, &names, &types, &attrs);
};
comptime {
for (std.meta.fields(@TypeOf(tab_modules))) |field| {
const Module = @field(tab_modules, field.name);
tab_framework.validateTabModule(Module);
}
}
/// Per-symbol fetched data. Owned by `App` and accessed as
/// `app.symbol_data.*`. Populated by whichever tab fetches first
/// (typically the perf or quote tab); consumed by every tab that
/// renders symbol-bound information (quote, perf, options, earnings).
///
/// Distinct from "tab-private state" in `app.states` because a
/// single tab doesn't own this data — it's a shared cache scoped
/// to "the current symbol." Cleared in `resetSymbolData` whenever
/// the user changes symbols.
///
/// **Cross-tab mutation note.** Today consumers re-read these
/// fields on every render frame, so mutations don't need
/// notification. If a future tab needs to react to a mutation
/// (e.g. recompute a derived value when `candles` changes rather
/// than re-derive on every render), the right escape valve is a
/// new framework lifecycle hook (e.g. `onSymbolDataChange`) that
/// tabs opt into via `@hasDecl`. We're not adding it speculatively
/// — the read-on-render pattern is sufficient for current
/// consumers.
pub const SymbolData = struct {
/// Daily OHLCV candles for the active symbol, oldest-first.
/// Owned by SymbolData; freed via `deinit` or `clear`.
candles: ?[]zfin.Candle = null,
/// Unix-epoch seconds for the candle fetch — drives the
/// "data Xs ago" header readout.
candle_timestamp: i64 = 0,
/// Dividend events. Owned by SymbolData; freed via `deinit`
/// or `clear`.
dividends: ?[]zfin.Dividend = null,
/// Trailing risk metrics (volatility, sharpe, max drawdown)
/// computed from the candle series.
risk_metrics: ?zfin.risk.TrailingRisk = null,
/// Trailing returns at the candle endpoint date (price-only
/// vs total-return — total requires dividend data).
trailing_price: ?zfin.performance.TrailingReturns = null,
trailing_total: ?zfin.performance.TrailingReturns = null,
/// Trailing returns at the most recent month-end (for
/// Morningstar-style reporting).
trailing_me_price: ?zfin.performance.TrailingReturns = null,
trailing_me_total: ?zfin.performance.TrailingReturns = null,
/// ETF profile (holdings, sectors). Loaded lazily on perf tab
/// activation; null for non-ETFs. The `etf_loaded` flag means
/// "we attempted the load" so we don't retry every activation.
etf_profile: ?zfin.EtfProfile = null,
etf_loaded: bool = false,
/// Free all owned slices. Idempotent — safe to call after
/// partial-load failure or repeated.
pub fn deinit(self: *SymbolData, allocator: std.mem.Allocator) void {
self.clear(allocator);
}
/// Free all owned slices and reset to defaults. Used on symbol
/// change so the next fetch starts from a clean slate.
pub fn clear(self: *SymbolData, allocator: std.mem.Allocator) void {
if (self.candles) |c| allocator.free(c);
if (self.dividends) |d| zfin.Dividend.freeSlice(allocator, d);
if (self.etf_profile) |profile| {
if (profile.holdings) |h| {
for (h) |holding| {
if (holding.symbol) |s| allocator.free(s);
allocator.free(holding.name);
}
allocator.free(h);
}
if (profile.sectors) |s| {
for (s) |sec| allocator.free(sec.name);
allocator.free(s);
}
}
self.* = .{};
}
// ── Derived projections of `candles` ──────────────────────
//
// These are functions, not fields, so they can't drift from
// the underlying `candles` slice. Renderers call them per-frame;
// the cost is a single deref + (for first/last) a slice index.
/// Number of cached candles. Zero when candles haven't loaded.
pub fn candleCount(self: *const SymbolData) usize {
return if (self.candles) |c| c.len else 0;
}
/// First (oldest) candle date, or null if no candles.
pub fn candleFirstDate(self: *const SymbolData) ?zfin.Date {
const c = self.candles orelse return null;
if (c.len == 0) return null;
return c[0].date;
}
/// Last (newest) candle date, or null if no candles.
pub fn candleLastDate(self: *const SymbolData) ?zfin.Date {
const c = self.candles orelse return null;
if (c.len == 0) return null;
return c[c.len - 1].date;
}
};
/// Per-portfolio shared data. Owned by `App` and accessed as
/// `app.portfolio.*`. Populated by `loadPortfolioData`; consumed
/// by every tab that reads portfolio-bound information (portfolio,
/// projections, history, analysis).
///
/// Distinct from "tab-private state" in `app.states` because a
/// single tab doesn't own this data — it's a shared cache scoped
/// to "the current portfolio file."
pub const PortfolioData = struct {
/// Parsed portfolio.srf (lots, watchlist, classifications).
/// The "portfolio" everyone refers to. Owned here; freed via
/// `deinit`.
file: ?zfin.Portfolio = null,
/// Computed summary (allocations, totals, gain/loss). Derived
/// from `file` + per-symbol prices. Refreshed on price updates.
summary: ?zfin.valuation.PortfolioSummary = null,
/// Whether the portfolio is loaded into `file`. Distinct from
/// `file != null` because the load may have failed and we
/// want to remember "we tried."
loaded: bool = false,
/// Historical snapshot values (1W/1M/1Q/1Y/3Y/5Y/10Y) for the
/// portfolio's value-over-time view. Populated on portfolio
/// load; null until then.
historical_snapshots: ?[zfin.valuation.HistoricalPeriod.all.len]zfin.valuation.HistoricalSnapshot = null,
/// Account-tax-type metadata loaded from `accounts.srf` next
/// to the portfolio. Used by analysis (tax-type breakdown)
/// and portfolio (per-account display).
///
/// **Cross-tab mutation note.** Analysis-tab refresh
/// (`tab_modules.analysis.tab.reload`) clears this field so the next
/// load re-reads `accounts.srf` from disk (the user may have
/// edited it). Portfolio-tab consumers re-read this field on
/// every render, so the clear-and-reload doesn't require a
/// notification today. If a future tab needs to react to the
/// clear (e.g. invalidate a cached aggregation), the right
/// escape valve is a new framework lifecycle hook
/// (e.g. `onAccountMapChange`) that tabs opt into via
/// `@hasDecl`. Not added speculatively.
account_map: ?zfin.analysis.AccountMap = null,
/// Cached prices for watchlist symbols (no live fetching during
/// render). Populated on portfolio load and refresh.
watchlist_prices: ?std.StringHashMap(f64) = null,
/// Most recent quote date across the portfolio's held symbols
/// (max of each symbol's last cached candle date). Drives the
/// "as of close on YYYY-MM-DD" line under the portfolio totals.
/// Computed as a side effect of the portfolio-prices loop in
/// `tab_modules.portfolio.loadPortfolioData`; null when no symbols have
/// cached candles.
latest_quote_date: ?zfin.Date = null,
pub fn deinit(self: *PortfolioData, allocator: std.mem.Allocator) void {
if (self.summary) |*s| s.deinit(allocator);
if (self.account_map) |*am| am.deinit();
if (self.watchlist_prices) |*wp| wp.deinit();
if (self.file) |*pf| pf.deinit();
self.* = .{};
}
};
/// Root widget for the interactive TUI. Implements the vaxis `vxfw.Widget`
/// interface via `widget()`, which wires `typeErasedEventHandler` and
/// `typeErasedDrawFn` as callbacks. Passed to `vxfw.App.run()` as the
/// top-level widget; vaxis drives the event loop, calling back into App
/// for key/mouse/init events and for each frame's draw.
///
/// Owns all application state: the active tab, cached data for each tab,
/// navigation/scroll positions, input mode, and a reference to the
/// `DataService` for fetching financial data. Tab-specific rendering and
/// data loading are delegated to the `tui/*_tab.zig` modules.
pub const App = struct {
allocator: std.mem.Allocator,
io: std.Io,
/// Per-tab private state. See `TabStates` above. Tabs that have
/// migrated to the framework own their fields under
/// `app.states.<tab>`; tabs not yet migrated still have their
/// fields directly on App.
states: TabStates = .{},
/// Per-symbol shared data (candles, dividends, trailing returns,
/// ETF profile). See `SymbolData` above. Cleared in
/// `resetSymbolData` on symbol change.
symbol_data: SymbolData = .{},
/// Per-portfolio shared data (loaded portfolio file, summary,
/// account map, watchlist prices, historical snapshots). See
/// `PortfolioData` above. Reloaded by
/// `tab_modules.portfolio.reloadPortfolioFile` on file changes.
portfolio: PortfolioData = .{},
/// Captured at App init and refreshed at tab change. Using a cached
/// date (rather than calling the clock on every render) keeps render
/// deterministic within a single frame and avoids threading `io`
/// through pure date-consuming helpers like `positions()`.
today: zfin.Date,
config: zfin.Config,
svc: *zfin.DataService,
keymap: keybinds.KeyMap,
theme: theme.Theme,
active_tab: Tab = .portfolio,
symbol: []const u8 = "",
symbol_buf: [16]u8 = undefined,
symbol_owned: bool = false,
scroll_offset: usize = 0,
visible_height: u16 = 24, // updated each draw
has_explicit_symbol: bool = false, // true if -s was used
portfolio_path: ?[]const u8 = null,
watchlist: ?[][]const u8 = null,
watchlist_path: ?[]const u8 = null,
status_msg: [256]u8 = undefined,
status_len: usize = 0,
// Input mode state
mode: InputMode = .normal,
input_buf: [16]u8 = undefined,
input_len: usize = 0,
// Portfolio tab state lives in `self.states.portfolio` (see TabStates).
// Account picker / search modal state. The portfolio tab opens
// the picker via the `account_filter` action, but the picker
// itself is a global UI mode (mode = .account_picker) that
// operates on portfolio state via `self.states.portfolio`.
// Search-mode is mode = .account_search.
account_picker_cursor: usize = 0, // cursor position in picker (0 = "All accounts")
account_search_buf: [64]u8 = undefined,
account_search_len: usize = 0,
account_search_matches: std.ArrayList(usize) = .empty, // indices into states.portfolio.account_list matching search
account_search_cursor: usize = 0, // cursor within search_matches
// History tab state lives in `self.states.history` (see TabStates).
// Projections tab state lives in `self.states.projections`.
// Mouse wheel debounce for cursor-based tabs (portfolio, options).
// Terminals often send multiple wheel events per physical tick.
last_wheel_ns: i128 = 0,
/// Global chart-rendering config (mode, max dimensions). Driven
/// by the `--chart` CLI flag at startup; not per-tab. Consumed by
/// any tab that renders pixel charts (quote, projections, future
/// forecast-evaluation views).
chart_config: chart.ChartConfig = .{},
vx_app: ?*vaxis.vxfw.App = null, // set during run(), for Kitty graphics access
pub fn widget(self: *App) vaxis.vxfw.Widget {
return .{
.userdata = self,
.eventHandler = typeErasedEventHandler,
.drawFn = typeErasedDrawFn,
};
}
fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vaxis.vxfw.EventContext, event: vaxis.vxfw.Event) anyerror!void {
const self: *App = @ptrCast(@alignCast(ptr));
switch (event) {
.key_press => |key| {
if (self.mode == .symbol_input) {
return self.handleInputKey(ctx, key);
}
if (self.mode == .date_input) {
return self.handleDateInputKey(ctx, key);
}
if (self.mode == .account_picker) {
return self.handleAccountPickerKey(ctx, key);
}
if (self.mode == .account_search) {
return self.handleAccountSearchKey(ctx, key);
}
if (self.mode == .help) {
self.mode = .normal;
return ctx.consumeAndRedraw();
}
return self.handleNormalKey(ctx, key);
},
.mouse => |mouse| {
return self.handleMouse(ctx, mouse);
},
.init => {
self.loadTabData();
},
else => {},
}
}
fn handleMouse(self: *App, ctx: *vaxis.vxfw.EventContext, mouse: vaxis.Mouse) void {
// Account picker mouse handling
if (self.mode == .account_picker) {
const total_items = self.states.portfolio.account_list.items.len + 1;
switch (mouse.button) {
.wheel_up => {
if (self.shouldDebounceWheel()) return;
if (self.account_picker_cursor > 0)
self.account_picker_cursor -= 1;
return ctx.consumeAndRedraw();
},
.wheel_down => {
if (self.shouldDebounceWheel()) return;
if (total_items > 0 and self.account_picker_cursor < total_items - 1)
self.account_picker_cursor += 1;
return ctx.consumeAndRedraw();
},
.left => {
if (mouse.type != .press) return;
// Map click row to picker item index.
// mouse.row maps directly to content line index
// (same convention as portfolio click handling).
const content_row = @as(usize, @intCast(mouse.row));
if (content_row >= tab_modules.portfolio.account_picker_header_lines) {
const item_idx = content_row - tab_modules.portfolio.account_picker_header_lines;
if (item_idx < total_items) {
self.account_picker_cursor = item_idx;
self.applyAccountPickerSelection();
return ctx.consumeAndRedraw();
}
}
},
else => {},
}
return;
}
switch (mouse.button) {
.wheel_up => {
self.moveBy(-3);
return ctx.consumeAndRedraw();
},
.wheel_down => {
self.moveBy(3);
return ctx.consumeAndRedraw();
},
.left => {
if (mouse.type != .press) return;
// Tab bar: click to switch tabs
if (mouse.row == 0) {
var col: i16 = 0;
for (tabs) |t| {
const lbl_len: i16 = @intCast(tabLabel(t).len);
if (mouse.col >= col and mouse.col < col + lbl_len) {
if (self.isDisabled(t)) return;
self.active_tab = t;
self.scroll_offset = 0;
self.loadTabData();
ctx.queueRefresh() catch {};
return ctx.consumeAndRedraw();
}
col += lbl_len;
}
}
// Framework dispatch: ask the active tab's `handleMouse`
// (when defined) if it wants to consume this click.
//
// Chrome ownership: row 0 is the tab bar — clicks
// that hit a tab label were already consumed above;
// misses are dropped here so tab handlers only see
// content-region events. (Future: a tab might want
// to opt in to chrome regions for per-tab indicators
// — would require a framework hook to claim chrome
// ranges, not just a row-0 bypass.)
//
// The bottom status row (`max_size.height - 1`) is
// also chrome but isn't filtered yet — none of the
// current tabs have content there, so clicks land
// harmlessly. Filter here when a tab grows
// bottom-edge content that needs disambiguation.
if (mouse.row > 0 and self.dispatchBool("handleMouse", .{mouse})) {
return ctx.consumeAndRedraw();
}
},
else => {},
}
}
/// Does the active tab declare the named hook? Pure predicate;
/// no dispatch. Used to gate work that conditionally fires
/// alongside a hook (e.g. wheel-debounce only for cursor-bearing
/// tabs in `moveBy`).
fn activeTabHas(self: *const App, comptime hook_name: []const u8) bool {
inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
if (std.mem.eql(u8, field.name, @tagName(self.active_tab))) {
const Module = @field(tab_modules, field.name);
return @hasDecl(Module.tab, hook_name);
}
}
return false;
}
/// Dispatch to a `bool`-returning optional hook on the active
/// tab. Returns the hook's result, or `false` if the active tab
/// doesn't declare the hook (so callers can treat "not declared"
/// the same as "declined to consume").
///
/// `args` is a tuple of the trailing arguments after `(state, app)`
/// — for `handleMouse`, that's `.{mouse}`; for `onCursorMove`,
/// `.{delta}`. The validator in `tab_framework` already enforces
/// each hook's full signature at comptime, so a typo'd `hook_name`
/// or wrong arg shape is caught when the registered tab module is
/// checked at module-init time.
///
/// `anytype` is justified here: this is generic dispatch over a
/// closed set of hook signatures whose shapes are independently
/// validated by `tab_framework.validateTabModule`. The framework
/// IS the type contract; this dispatcher is the runtime accessor.
fn dispatchBool(self: *App, comptime hook_name: []const u8, args: anytype) bool {
inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
if (std.mem.eql(u8, field.name, @tagName(self.active_tab))) {
const Module = @field(tab_modules, field.name);
if (!@hasDecl(Module.tab, hook_name)) return false;
const fn_ptr = @field(Module.tab, hook_name);
const state_ptr = &@field(self.states, field.name);
return @call(.auto, fn_ptr, .{ state_ptr, self } ++ args);
}
}
return false;
}
/// Dispatch to a `void`-returning optional hook on the active
/// tab. No-op if the active tab doesn't declare the hook.
/// See `dispatchBool` for the `args` tuple convention and the
/// rationale for the `anytype` parameter.
fn dispatchVoid(self: *App, comptime hook_name: []const u8, args: anytype) void {
inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
if (std.mem.eql(u8, field.name, @tagName(self.active_tab))) {
const Module = @field(tab_modules, field.name);
if (!@hasDecl(Module.tab, hook_name)) return;
const fn_ptr = @field(Module.tab, hook_name);
const state_ptr = &@field(self.states, field.name);
@call(.auto, fn_ptr, .{ state_ptr, self } ++ args);
return;
}
}
}
/// Broadcast to every tab that declares `hook_name` — not just
/// the active tab. Tabs that don't declare it are skipped.
/// Order is `tab_modules` declaration order; callers should not
/// rely on a particular order across tabs (each tab's hook is
/// expected to handle the change independently).
///
/// Used for global context changes that every interested tab
/// needs to react to (e.g. `onSymbolChange` — every tab with
/// per-symbol cached state opts in to drop it). Distinct from
/// `dispatchVoid`, which only notifies the active tab.
///
/// Only meaningful for void-returning hooks; bool-returning
/// hooks have ambiguous broadcast semantics ("all consumed"?
/// "any consumed"?) so the framework declines to define them.
/// See `dispatchBool` for the `args` tuple convention and the
/// rationale for `anytype`.
fn broadcast(self: *App, comptime hook_name: []const u8, args: anytype) void {
inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
const Module = @field(tab_modules, field.name);
if (@hasDecl(Module.tab, hook_name)) {
const fn_ptr = @field(Module.tab, hook_name);
const state_ptr = &@field(self.states, field.name);
@call(.auto, fn_ptr, .{ state_ptr, self } ++ args);
}
}
}
/// Call a `bool`-returning App-level predicate hook on the
/// specified tab (not necessarily the active tab). Returns
/// `false` if the tab doesn't declare the hook. Distinct from
/// `dispatchBool` because the hook signature is `fn(*App)bool`
/// — App-only, no tab State arg. Used for predicates like
/// `isDisabled` that depend on App-level context (whether a
/// portfolio is loaded, etc.) rather than tab-private state.
fn appPredicate(self: *App, target: Tab, comptime hook_name: []const u8) bool {
inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
if (std.mem.eql(u8, field.name, @tagName(target))) {
const Module = @field(tab_modules, field.name);
if (!@hasDecl(Module.tab, hook_name)) return false;
return @field(Module.tab, hook_name)(self);
}
}
return false;
}
/// Outcome of a single keypress in an input-mode buffer (symbol
/// input, date input, etc.). Returned by `handleInputBuffer` so
/// the per-mode caller only needs to wire up the `committed`
/// branch with its own semantics; the shared scaffolding (Esc to
/// cancel, Backspace/Ctrl+U to edit, printable to append) is
/// handled once.
const InputBufferResult = enum {
/// Esc pressed. Caller should exit input mode; the shared
/// helper has already reset `input_len` and set mode back to
/// `.normal`.
cancelled,
/// Enter pressed. Caller reads `self.input_buf[0..self.input_len]`
/// to commit, then resets mode + length.
committed,
/// Character appended / removed / cleared. Caller should just
/// redraw; no further action.
edited,
/// Key didn't match any input-buffer semantic (e.g., a
/// function key). Caller may ignore or layer on its own
/// handling; the helper didn't consume the event.
ignored,
};
/// Shared input-buffer state machine. Handles Esc (cancel),
/// Backspace/Ctrl+U (edit), and printable-ASCII append. Returns
/// the outcome so the caller can wire up Enter and Esc/edit
/// side-effects on its own.
///
/// Behavior on `cancelled`: resets `self.mode = .normal` and
/// `self.input_len = 0`. Caller typically sets a status message
/// and calls `ctx.consumeAndRedraw()`.
///
/// Does not touch state on `committed` — caller owns the commit
/// (reading the buffer, dispatching to downstream, resetting
/// mode/length when done).
fn handleInputBuffer(self: *App, key: vaxis.Key) InputBufferResult {
if (key.codepoint == vaxis.Key.escape) {
self.mode = .normal;
self.input_len = 0;
return .cancelled;
}
if (key.codepoint == vaxis.Key.enter) {
return .committed;
}
if (key.codepoint == vaxis.Key.backspace) {
if (self.input_len > 0) self.input_len -= 1;
return .edited;
}
// Ctrl+U: clear entire input (readline convention)
if (key.matches('u', .{ .ctrl = true })) {
self.input_len = 0;
return .edited;
}
// Accept printable ASCII (letters, digits, common punctuation).
if (key.codepoint < std.math.maxInt(u7) and std.ascii.isPrint(@intCast(key.codepoint)) and self.input_len < self.input_buf.len) {
self.input_buf[self.input_len] = @intCast(key.codepoint);
self.input_len += 1;
return .edited;
}
return .ignored;
}
/// Handles keypresses in symbol_input mode (activated by `/`).
/// Mini text input for typing a ticker symbol (e.g. AAPL, BRK.B, ^GSPC).
fn handleInputKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void {
switch (self.handleInputBuffer(key)) {
.cancelled => {
self.setStatus("Cancelled");
return ctx.consumeAndRedraw();
},
.edited => return ctx.consumeAndRedraw(),
.ignored => {},
.committed => {
// Commit: uppercase the input, set as active symbol, switch to quote tab
if (self.input_len > 0) {
for (self.input_buf[0..self.input_len]) |*ch| ch.* = std.ascii.toUpper(ch.*);
@memcpy(self.symbol_buf[0..self.input_len], self.input_buf[0..self.input_len]);
self.symbol = self.symbol_buf[0..self.input_len];
self.symbol_owned = true;
self.has_explicit_symbol = true;
self.resetSymbolData();
self.active_tab = .quote;
self.loadTabData();
ctx.queueRefresh() catch {};
}
self.mode = .normal;
self.input_len = 0;
return ctx.consumeAndRedraw();
},
}
}
/// Handles keypresses in date_input mode (activated by `d` on the
/// projections tab).
///
/// Accepts the same input as the CLI `--as-of` flag — `YYYY-MM-DD`,
/// relative shortcuts (`1W`, `1M`, `3M`, `1Q`, `1Y`, `3Y`, `5Y`),
/// or `live` / empty for live state. Commit via Enter, cancel via
/// Esc.
fn handleDateInputKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void {
switch (self.handleInputBuffer(key)) {
.cancelled => {
self.setStatus("Cancelled");
return ctx.consumeAndRedraw();
},
.edited => return ctx.consumeAndRedraw(),
.ignored => {},
.committed => {
const input = self.input_buf[0..self.input_len];
const parsed = cli.parseAsOfDate(input, self.today) catch |err| {
var buf: [256]u8 = undefined;
const msg = cli.fmtAsOfParseError(&buf, input, err);
self.setStatus(msg);
self.mode = .normal;
self.input_len = 0;
return ctx.consumeAndRedraw();
};
if (parsed) |d| {
// Guard against future dates.
if (d.days > self.today.days) {
self.setStatus("As-of date is in the future");
self.mode = .normal;
self.input_len = 0;
return ctx.consumeAndRedraw();
}
self.states.projections.as_of = d;
self.states.projections.as_of_requested = null;
var status_buf: [64]u8 = undefined;
const msg = std.fmt.bufPrint(&status_buf, "As-of: {f}", .{d}) catch "As-of set";
self.setStatus(msg);
} else {
// `null` parse result = live.
self.states.projections.as_of = null;
self.states.projections.as_of_requested = null;
self.setStatus("As-of cleared — showing live");
}
tab_modules.projections.tab.reload(&self.states.projections, self) catch {};
self.mode = .normal;
self.input_len = 0;
ctx.queueRefresh() catch {};
return ctx.consumeAndRedraw();
},
}
}
/// Handles keypresses in account_picker mode.
fn handleAccountPickerKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void {
const total_items = self.states.portfolio.account_list.items.len + 1; // +1 for "All accounts"
if (key.codepoint == vaxis.Key.escape or key.codepoint == 'q') {
self.mode = .normal;
return ctx.consumeAndRedraw();
}
if (key.codepoint == vaxis.Key.enter) {
self.applyAccountPickerSelection();
return ctx.consumeAndRedraw();
}
// '/' enters search mode
if (key.matches('/', .{})) {
self.mode = .account_search;
self.account_search_len = 0;
self.updateAccountSearchMatches();
return ctx.consumeAndRedraw();
}
// 'A' selects "All accounts" instantly
if (key.matches('A', .{})) {
self.account_picker_cursor = 0;
self.applyAccountPickerSelection();
return ctx.consumeAndRedraw();
}
// Check shortcut keys for instant selection
if (key.codepoint < std.math.maxInt(u7) and key.matches(key.codepoint, .{})) {
const ch: u8 = @intCast(key.codepoint);
for (self.states.portfolio.account_shortcut_keys.items, 0..) |shortcut, i| {
if (shortcut == ch) {
self.account_picker_cursor = i + 1; // +1 for "All accounts" at 0
self.applyAccountPickerSelection();
return ctx.consumeAndRedraw();
}
}
}
// Navigation via keymap
const action = self.keymap.matchAction(key) orelse return;
switch (action) {
.select_next => {
if (total_items > 0 and self.account_picker_cursor < total_items - 1)
self.account_picker_cursor += 1;
return ctx.consumeAndRedraw();
},
.select_prev => {
if (self.account_picker_cursor > 0)
self.account_picker_cursor -= 1;
return ctx.consumeAndRedraw();
},
.scroll_top => {
self.account_picker_cursor = 0;
return ctx.consumeAndRedraw();
},
.scroll_bottom => {
if (total_items > 0)
self.account_picker_cursor = total_items - 1;
return ctx.consumeAndRedraw();
},
else => {},
}
}
/// Handles keypresses in account_search mode (/ search within picker).
fn handleAccountSearchKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void {
// Escape: cancel search, return to picker
if (key.codepoint == vaxis.Key.escape) {
self.mode = .account_picker;
self.account_search_len = 0;
return ctx.consumeAndRedraw();
}
// Enter: select the first match (or current search cursor)
if (key.codepoint == vaxis.Key.enter) {
if (self.account_search_matches.items.len > 0) {
const match_idx = self.account_search_matches.items[self.account_search_cursor];
self.account_picker_cursor = match_idx + 1; // +1 for "All accounts"
}
self.account_search_len = 0;
self.applyAccountPickerSelection();
return ctx.consumeAndRedraw();
}
// Ctrl+N / Ctrl+P or arrow keys to cycle through matches
if (key.matches('n', .{ .ctrl = true }) or key.codepoint == vaxis.Key.down) {
if (self.account_search_matches.items.len > 0 and
self.account_search_cursor < self.account_search_matches.items.len - 1)
self.account_search_cursor += 1;
return ctx.consumeAndRedraw();
}
if (key.matches('p', .{ .ctrl = true }) or key.codepoint == vaxis.Key.up) {
if (self.account_search_cursor > 0)
self.account_search_cursor -= 1;
return ctx.consumeAndRedraw();
}
// Backspace
if (key.codepoint == vaxis.Key.backspace) {
if (self.account_search_len > 0) {
self.account_search_len -= 1;
self.updateAccountSearchMatches();
}
return ctx.consumeAndRedraw();
}
// Ctrl+U: clear search
if (key.matches('u', .{ .ctrl = true })) {
self.account_search_len = 0;
self.updateAccountSearchMatches();
return ctx.consumeAndRedraw();
}
// Printable ASCII
if (key.codepoint < std.math.maxInt(u7) and std.ascii.isPrint(@intCast(key.codepoint)) and self.account_search_len < self.account_search_buf.len) {
self.account_search_buf[self.account_search_len] = @intCast(key.codepoint);
self.account_search_len += 1;
self.updateAccountSearchMatches();
return ctx.consumeAndRedraw();
}
}
/// Update search match indices based on current search string.
fn updateAccountSearchMatches(self: *App) void {
self.account_search_matches.clearRetainingCapacity();
const query = self.account_search_buf[0..self.account_search_len];
if (query.len == 0) return;
var lower_query: [64]u8 = undefined;
for (query, 0..) |c, i| lower_query[i] = std.ascii.toLower(c);
const lq = lower_query[0..query.len];
for (self.states.portfolio.account_list.items, 0..) |acct, i| {
if (containsLower(acct, lq)) {
self.account_search_matches.append(self.allocator, i) catch continue;
} else if (i < self.states.portfolio.account_numbers.items.len) {
if (self.states.portfolio.account_numbers.items[i]) |num| {
if (containsLower(num, lq)) {
self.account_search_matches.append(self.allocator, i) catch continue;
}
}
}
}
if (self.account_search_cursor >= self.account_search_matches.items.len) {
self.account_search_cursor = if (self.account_search_matches.items.len > 0)
self.account_search_matches.items.len - 1
else
0;
}
}
fn containsLower(haystack: []const u8, needle_lower: []const u8) bool {
if (needle_lower.len == 0) return true;
if (haystack.len < needle_lower.len) return false;
const end = haystack.len - needle_lower.len + 1;
for (0..end) |start| {
var matched = true;
for (0..needle_lower.len) |j| {
if (std.ascii.toLower(haystack[start + j]) != needle_lower[j]) {
matched = false;
break;
}
}
if (matched) return true;
}
return false;
}
/// Apply the current account picker selection and return to normal mode.
fn applyAccountPickerSelection(self: *App) void {
if (self.account_picker_cursor == 0) {
// "All accounts" — clear filter
self.setAccountFilter(null);
} else {
const idx = self.account_picker_cursor - 1;
if (idx < self.states.portfolio.account_list.items.len) {
self.setAccountFilter(self.states.portfolio.account_list.items[idx]);
}
}
self.mode = .normal;
self.states.portfolio.cursor = 0;
self.scroll_offset = 0;
tab_modules.portfolio.rebuildPortfolioRows(&self.states.portfolio, self);
if (self.states.portfolio.account_filter) |af| {
var tmp_buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&tmp_buf, "Filtered: {s}", .{af}) catch "Filtered";
self.setStatus(msg);
} else {
self.setStatus("Filter cleared: showing all accounts");
}
}
/// Load accounts.srf if not already loaded. Derives path from portfolio_path.
pub fn ensureAccountMap(self: *App) void {
if (self.portfolio.account_map != null) return;
const ppath = self.portfolio_path orelse return;
self.portfolio.account_map = self.svc.loadAccountMap(ppath);
}
/// Set or clear the account filter. Owns the string via allocator.
pub fn setAccountFilter(self: *App, name: ?[]const u8) void {
if (self.states.portfolio.account_filter) |old| self.allocator.free(old);
if (self.states.portfolio.filtered_positions) |fp| self.allocator.free(fp);
self.states.portfolio.filtered_positions = null;
if (name) |n| {
self.states.portfolio.account_filter = self.allocator.dupe(u8, n) catch null;
if (self.portfolio.file) |pf| {
self.states.portfolio.filtered_positions = pf.positionsForAccount(self.today, self.allocator, n) catch null;
}
} else {
self.states.portfolio.account_filter = null;
}
}
fn handleNormalKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void {
// Ctrl+L: full screen redraw (standard TUI convention, not configurable)
if (key.codepoint == 'l' and key.mods.ctrl) {
ctx.queueRefresh() catch {};
return ctx.consumeAndRedraw();
}
// History-tab compare intercept.
//
// `s` / space / `c` / escape have existing global bindings
// (select_symbol, collapse_all_calls, plus the account-filter
// escape handler below) that would otherwise handle (or
// silently consume) these keys. This intercept runs first when
// the user is in the history tab so compare behavior wins.
if (self.active_tab == .history) {
if (tab_modules.history.handleCompareKey(&self.states.history, self, ctx, key)) return;
}
// Escape: clear account filter on portfolio tab, clear as-of
// on projections tab, no-op otherwise.
if (key.codepoint == vaxis.Key.escape) {
if (self.active_tab == .portfolio and self.states.portfolio.account_filter != null) {
self.setAccountFilter(null);
self.states.portfolio.cursor = 0;
self.scroll_offset = 0;
tab_modules.portfolio.rebuildPortfolioRows(&self.states.portfolio, self);
self.setStatus("Filter cleared: showing all accounts");
return ctx.consumeAndRedraw();
}
if (self.active_tab == .projections and self.states.projections.as_of != null) {
self.states.projections.as_of = null;
self.states.projections.as_of_requested = null;
self.states.projections.overlay_actuals = false;
tab_modules.projections.tab.reload(&self.states.projections, self) catch {};
self.setStatus("As-of cleared — showing live");
return ctx.consumeAndRedraw();
}
return;
}
const action = self.keymap.matchAction(key) orelse return;
switch (action) {
.quit => {
ctx.quit = true;
},
.symbol_input => {
self.mode = .symbol_input;
self.input_len = 0;
return ctx.consumeAndRedraw();
},
.select_symbol => {
// 's' selects the current portfolio row's symbol as the active symbol
if (self.active_tab == .portfolio) {
tab_modules.portfolio.tab.handleAction(&self.states.portfolio, self, tab_modules.portfolio.Action.select_symbol);
return ctx.consumeAndRedraw();
}
},
.refresh => {
self.refreshCurrentTab();
return ctx.consumeAndRedraw();
},
.prev_tab => {
self.prevTab();
self.scroll_offset = 0;
self.loadTabData();
ctx.queueRefresh() catch {};
return ctx.consumeAndRedraw();
},
.next_tab => {
self.nextTab();
self.scroll_offset = 0;
self.loadTabData();
ctx.queueRefresh() catch {};
return ctx.consumeAndRedraw();
},
.tab_1, .tab_2, .tab_3, .tab_4, .tab_5, .tab_6, .tab_7, .tab_8 => {
const idx = @intFromEnum(action) - @intFromEnum(keybinds.Action.tab_1);
if (idx < tabs.len) {
const target = tabs[idx];
if (self.isDisabled(target)) return;
self.active_tab = target;
self.scroll_offset = 0;
self.loadTabData();
ctx.queueRefresh() catch {};
return ctx.consumeAndRedraw();
}
},
.select_next => {
self.moveBy(1);
return ctx.consumeAndRedraw();
},
.select_prev => {
self.moveBy(-1);
return ctx.consumeAndRedraw();
},
.expand_collapse => {
if (self.active_tab == .portfolio) {
tab_modules.portfolio.tab.handleAction(&self.states.portfolio, self, tab_modules.portfolio.Action.expand_collapse);
return ctx.consumeAndRedraw();
} else if (self.active_tab == .options) {
tab_modules.options.tab.handleAction(&self.states.options, self, tab_modules.options.Action.expand_collapse);
return ctx.consumeAndRedraw();
} else if (self.active_tab == .history) {
tab_modules.history.tab.handleAction(&self.states.history, self, tab_modules.history.Action.expand_collapse);
return ctx.consumeAndRedraw();
}
},
.scroll_down => {
const half = @max(1, self.visible_height / 2);
self.scroll_offset += half;
return ctx.consumeAndRedraw();
},
.scroll_up => {
const half = @max(1, self.visible_height / 2);
if (self.scroll_offset > half) self.scroll_offset -= half else self.scroll_offset = 0;
return ctx.consumeAndRedraw();
},
.page_down => {
self.scroll_offset += self.visible_height;
return ctx.consumeAndRedraw();
},
.page_up => {
if (self.scroll_offset > self.visible_height)
self.scroll_offset -= self.visible_height
else
self.scroll_offset = 0;
return ctx.consumeAndRedraw();
},
.scroll_top => {
self.scroll_offset = 0;
self.dispatchVoid("onScroll", .{tab_framework.ScrollEdge.top});
return ctx.consumeAndRedraw();
},
.scroll_bottom => {
self.scroll_offset = std.math.maxInt(usize) / 2; // clamped during draw...divide by 2 to avoid overflow if arithmetic is done
self.dispatchVoid("onScroll", .{tab_framework.ScrollEdge.bottom});
return ctx.consumeAndRedraw();
},
.help => {
self.mode = .help;
self.scroll_offset = 0;
return ctx.consumeAndRedraw();
},
.reload_portfolio => {
self.reloadPortfolioFile();
return ctx.consumeAndRedraw();
},
.collapse_all_calls => {
if (self.active_tab == .options) {
tab_modules.options.tab.handleAction(&self.states.options, self, tab_modules.options.Action.collapse_all_calls);
return ctx.consumeAndRedraw();
}
},
.collapse_all_puts => {
if (self.active_tab == .options) {
tab_modules.options.tab.handleAction(&self.states.options, self, tab_modules.options.Action.collapse_all_puts);
return ctx.consumeAndRedraw();
}
},
.options_filter_1, .options_filter_2, .options_filter_3, .options_filter_4, .options_filter_5, .options_filter_6, .options_filter_7, .options_filter_8, .options_filter_9 => {
if (self.active_tab == .options) {
const n = @intFromEnum(action) - @intFromEnum(keybinds.Action.options_filter_1) + 1;
// Route through tab.handleAction so the dispatch
// table is consistent with the framework. Each
// filter_N maps directly to tab_modules.options.Action.filter_N.
const tab_action: tab_modules.options.Action = @enumFromInt(@intFromEnum(tab_modules.options.Action.filter_1) + n - 1);
tab_modules.options.tab.handleAction(&self.states.options, self, tab_action);
return ctx.consumeAndRedraw();
}
},
.chart_timeframe_next => {
if (self.active_tab == .quote) {
tab_modules.quote.tab.handleAction(&self.states.quote, self, tab_modules.quote.Action.chart_timeframe_next);
return ctx.consumeAndRedraw();
}
},
.chart_timeframe_prev => {
if (self.active_tab == .quote) {
tab_modules.quote.tab.handleAction(&self.states.quote, self, tab_modules.quote.Action.chart_timeframe_prev);
return ctx.consumeAndRedraw();
}
},
.history_metric_next => {
if (self.active_tab == .history) {
tab_modules.history.tab.handleAction(&self.states.history, self, tab_modules.history.Action.metric_next);
return ctx.consumeAndRedraw();
}
},
.history_resolution_next => {
if (self.active_tab == .history) {
tab_modules.history.tab.handleAction(&self.states.history, self, tab_modules.history.Action.resolution_next);
return ctx.consumeAndRedraw();
}
},
.sort_col_next => {
if (self.active_tab == .portfolio) {
tab_modules.portfolio.tab.handleAction(&self.states.portfolio, self, tab_modules.portfolio.Action.sort_col_next);
return ctx.consumeAndRedraw();
}
},
.sort_col_prev => {
if (self.active_tab == .portfolio) {
tab_modules.portfolio.tab.handleAction(&self.states.portfolio, self, tab_modules.portfolio.Action.sort_col_prev);
return ctx.consumeAndRedraw();
}
},
.sort_reverse => {
// The `o` keybind dual-dispatches by active tab:
// - portfolio → flip the sort direction
// - projections → toggle the actuals-overlay
// `matchAction` is first-match-wins so we can't have
// separate Action variants share a codepoint; routing
// via the active tab in the handler is the project's
// existing pattern (see also `s` → compare_select /
// select_symbol). The action name stays `sort_reverse`
// because portfolio was the first consumer.
if (self.active_tab == .portfolio) {
tab_modules.portfolio.tab.handleAction(&self.states.portfolio, self, tab_modules.portfolio.Action.sort_reverse);
return ctx.consumeAndRedraw();
}
if (self.active_tab == .projections) {
tab_modules.projections.tab.handleAction(&self.states.projections, self, tab_modules.projections.Action.overlay_actuals);
return ctx.consumeAndRedraw();
}
},
.account_filter => {
if (self.active_tab == .portfolio) {
tab_modules.portfolio.tab.handleAction(&self.states.portfolio, self, tab_modules.portfolio.Action.open_account_picker);
return ctx.consumeAndRedraw();
}
},
.toggle_chart => {
if (self.active_tab == .projections) {
tab_modules.projections.tab.handleAction(&self.states.projections, self, tab_modules.projections.Action.toggle_chart);
return ctx.consumeAndRedraw();
}
},
.toggle_events => {
if (self.active_tab == .projections) {
tab_modules.projections.tab.handleAction(&self.states.projections, self, tab_modules.projections.Action.toggle_events);
return ctx.consumeAndRedraw();
}
},
.projections_as_of_input => {
// Only meaningful on the projections tab. Other tabs
// let the same key flow to their own handlers (none
// currently bind plain 'd').
if (self.active_tab == .projections) {
tab_modules.projections.tab.handleAction(&self.states.projections, self, tab_modules.projections.Action.as_of_input);
return ctx.consumeAndRedraw();
}
},
// History-tab compare actions are normally intercepted in
// `handleCompareKey` before `matchAction` runs (because the
// default 's'/'c'/space/escape key bindings belong to other
// actions). These cases exist so the switch is exhaustive
// and so future user-supplied keybindings targeting these
// action names work correctly.
.compare_select => {
if (self.active_tab == .history) {
const hs = &self.states.history;
if (hs.compare_view == null and hs.table_row_count > 0) {
tab_modules.history.toggleSelectionAt(hs, self, hs.cursor);
return ctx.consumeAndRedraw();
}
}
},
.compare_commit => {
if (self.active_tab == .history) {
const hs = &self.states.history;
if (hs.compare_view != null) {
tab_modules.history.clearCompareState(hs, self);
} else {
tab_modules.history.commitCompareExternal(hs, self);
}
return ctx.consumeAndRedraw();
}
},
.compare_cancel => {
if (self.active_tab == .history) {
tab_modules.history.clearCompareState(&self.states.history, self);
return ctx.consumeAndRedraw();
}
},
}
}
/// Returns true if this wheel event should be suppressed (too close to the last one).
fn shouldDebounceWheel(self: *App) bool {
// wall-clock required: input-event debounce needs the actual
// monotonic moment this wheel event arrived, not a frame-captured
// approximation. `.awake` (monotonic) resists system clock jumps.
const now: i128 = @intCast(std.Io.Timestamp.now(self.io, .awake).nanoseconds);
if (now - self.last_wheel_ns < 1 * std.time.ns_per_ms) return true;
self.last_wheel_ns = now;
return false;
}
/// Move cursor/scroll. Positive = down, negative = up.
/// For tabs with a row cursor, moves the cursor by 1 with
/// debounce to absorb duplicate events from mouse wheel ticks.
/// For other tabs (or cursor-bearing tabs with empty rows),
/// adjusts scroll_offset by |n|.
fn moveBy(self: *App, n: isize) void {
// Migrated cursor-bearing tabs (portfolio, options, history).
// 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 {
const abs: usize = @intCast(-n);
if (self.scroll_offset > abs) self.scroll_offset -= abs else self.scroll_offset = 0;
}
}
pub fn setActiveSymbol(self: *App, sym: []const u8) void {
const len = @min(sym.len, self.symbol_buf.len);
@memcpy(self.symbol_buf[0..len], sym[0..len]);
for (self.symbol_buf[0..len]) |*c| c.* = std.ascii.toUpper(c.*);
self.symbol = self.symbol_buf[0..len];
self.symbol_owned = true;
self.has_explicit_symbol = true;
self.resetSymbolData();
}
fn resetSymbolData(self: *App) void {
// Tab-private symbol-bound state is dropped via each tab's
// onSymbolChange hook (where defined). Distinct from
// `tab.deinit` (App teardown) and `tab.reload` (drops AND
// re-fetches) — these hooks just drop the cache; the next
// `activate` will re-fetch lazily.
self.broadcast("onSymbolChange", .{});
// App-level shared per-symbol cache.
self.symbol_data.clear(self.allocator);
self.scroll_offset = 0;
}
fn refreshCurrentTab(self: *App) void {
// Invalidate cache so the next load forces a fresh fetch
if (self.symbol.len > 0) {
switch (self.active_tab) {
.quote, .performance => {
self.svc.invalidate(self.symbol, .candles_daily);
self.svc.invalidate(self.symbol, .dividends);
},
.earnings => {
self.svc.invalidate(self.symbol, .earnings);
},
.options => {
self.svc.invalidate(self.symbol, .options);
},
.portfolio, .analysis, .history, .projections => {},
}
}
switch (self.active_tab) {
.portfolio => {
self.portfolio.loaded = false;
self.freePortfolioSummary();
},
.quote, .performance => {
self.states.performance.loaded = false;
if (self.symbol_data.candles) |c| self.allocator.free(c);
self.symbol_data.candles = null;
if (self.symbol_data.dividends) |d| zfin.Dividend.freeSlice(self.allocator, d);
self.symbol_data.dividends = null;
self.states.quote.chart.dirty = true;
self.states.quote.chart.freeCache(self.allocator); // Invalidate indicator cache
},
.earnings => {
tab_modules.earnings.tab.reload(&self.states.earnings, self) catch {};
},
.options => {
tab_modules.options.tab.reload(&self.states.options, self) catch {};
},
.analysis => {
tab_modules.analysis.tab.reload(&self.states.analysis, self) catch {};
},
.history => {
tab_modules.history.tab.reload(&self.states.history, self) catch {};
},
.projections => {
tab_modules.projections.tab.reload(&self.states.projections, self) catch {};
},
}
self.loadTabData();
// After reload, fetch live quote for active symbol (costs 1 API call)
switch (self.active_tab) {
.quote, .performance => {
if (self.symbol.len > 0) {
if (self.svc.getQuote(self.symbol)) |q| {
self.states.quote.live = q;
// wall-clock required: records the exact moment
// this quote was served so the "refreshed Xs ago"
// display is honest about freshness.
self.states.quote.timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds();
} else |_| {}
}
},
else => {},
}
}
pub fn loadTabData(self: *App) void {
switch (self.active_tab) {
.portfolio => {
tab_modules.portfolio.tab.activate(&self.states.portfolio, self) catch {};
},
.quote, .performance => {
if (self.symbol.len == 0) return;
tab_modules.performance.tab.activate(&self.states.performance, self) catch {};
},
.earnings => {
if (self.symbol.len == 0) return;
tab_modules.earnings.tab.activate(&self.states.earnings, self) catch {};
},
.options => {
if (self.symbol.len == 0) return;
tab_modules.options.tab.activate(&self.states.options, self) catch {};
},
.analysis => {
tab_modules.analysis.tab.activate(&self.states.analysis, self) catch {};
},
.history => {
tab_modules.history.tab.activate(&self.states.history, self) catch {};
},
.projections => {
tab_modules.projections.tab.activate(&self.states.projections, self) catch {};
},
}
}
pub fn loadPortfolioData(self: *App) void {
tab_modules.portfolio.loadPortfolioData(&self.states.portfolio, self);
}
pub fn setStatus(self: *App, msg: []const u8) void {
const len = @min(msg.len, self.status_msg.len);
@memcpy(self.status_msg[0..len], msg[0..len]);
self.status_len = len;
}
fn getStatus(self: *App) []const u8 {
if (self.status_len == 0) return "h/l tabs | j/k select | Enter expand | s select | / symbol | ? help";
return self.status_msg[0..self.status_len];
}
pub fn freePortfolioSummary(self: *App) void {
if (self.portfolio.summary) |*s| s.deinit(self.allocator);
self.portfolio.summary = null;
}
fn deinitData(self: *App) void {
self.symbol_data.deinit(self.allocator);
tab_modules.earnings.tab.deinit(&self.states.earnings, self);
tab_modules.options.tab.deinit(&self.states.options, self);
tab_modules.portfolio.tab.deinit(&self.states.portfolio, self);
self.account_search_matches.deinit(self.allocator);
tab_modules.analysis.tab.deinit(&self.states.analysis, self);
self.portfolio.deinit(self.allocator);
tab_modules.history.tab.deinit(&self.states.history, self);
tab_modules.projections.tab.deinit(&self.states.projections, self);
tab_modules.quote.tab.deinit(&self.states.quote, self);
}
fn reloadPortfolioFile(self: *App) void {
tab_modules.portfolio.reloadPortfolioFile(&self.states.portfolio, self);
}
// ── Drawing ──────────────────────────────────────────────────
fn typeErasedDrawFn(ptr: *anyopaque, ctx: vaxis.vxfw.DrawContext) std.mem.Allocator.Error!vaxis.vxfw.Surface {
const self: *App = @ptrCast(@alignCast(ptr));
const max_size = ctx.max.size();
if (max_size.height < 3) {
return .{ .size = max_size, .widget = self.widget(), .buffer = &.{}, .children = &.{} };
}
self.visible_height = max_size.height -| 2;
var children: std.ArrayList(vaxis.vxfw.SubSurface) = .empty;
const tab_surface = try self.drawTabBar(ctx, max_size.width);
try children.append(ctx.arena, .{ .origin = .{ .row = 0, .col = 0 }, .surface = tab_surface });
const content_height = max_size.height - 2;
const content_surface = try self.drawContent(ctx, max_size.width, content_height);
try children.append(ctx.arena, .{ .origin = .{ .row = 1, .col = 0 }, .surface = content_surface });
const status_surface = try self.drawStatusBar(ctx, max_size.width);
try children.append(ctx.arena, .{ .origin = .{ .row = @intCast(max_size.height - 1), .col = 0 }, .surface = status_surface });
return .{ .size = max_size, .widget = self.widget(), .buffer = &.{}, .children = try children.toOwnedSlice(ctx.arena) };
}
fn drawTabBar(self: *App, ctx: vaxis.vxfw.DrawContext, width: u16) !vaxis.vxfw.Surface {
const th = self.theme;
const buf = try ctx.arena.alloc(vaxis.Cell, width);
const inactive_style = th.tabStyle();
@memset(buf, .{ .char = .{ .grapheme = " " }, .style = inactive_style });
var col: usize = 0;
for (tabs) |t| {
const lbl = tabLabel(t);
const is_active = t == self.active_tab;
const is_disabled = self.isDisabled(t);
const tab_style: vaxis.Style = if (is_active) th.tabActiveStyle() else if (is_disabled) th.tabDisabledStyle() else inactive_style;
for (lbl) |ch| {
if (col >= width) break;
buf[col] = .{ .char = .{ .grapheme = glyph(ch) }, .style = tab_style };
col += 1;
}
}
// Right-align the active symbol if set
if (self.symbol.len > 0) {
const is_selected = self.isSymbolSelected();
const prefix: []const u8 = if (is_selected) " * " else " ";
const sym_label = try std.fmt.allocPrint(ctx.arena, "{s}{s} ", .{ prefix, self.symbol });
if (width > sym_label.len + col) {
const sym_start = width - sym_label.len;
const sym_style: vaxis.Style = .{
.fg = theme.Theme.vcolor(if (is_selected) th.warning else th.info),
.bg = theme.Theme.vcolor(th.tab_bg),
.bold = is_selected,
};
for (0..sym_label.len) |i| {
buf[sym_start + i] = .{ .char = .{ .grapheme = glyph(sym_label[i]) }, .style = sym_style };
}
}
}
return .{ .size = .{ .width = width, .height = 1 }, .widget = self.widget(), .buffer = buf, .children = &.{} };
}
/// Whether the given tab should be treated as disabled in
/// the current App context. All migrated tabs are consulted
/// via their framework-contract `isDisabled` hook. (Portfolio
/// is the only remaining unmigrated tab; it has no disabled
/// predicate today.)
fn isDisabled(self: *App, t: Tab) bool {
return self.appPredicate(t, "isDisabled");
}
fn isSymbolSelected(self: *App) bool {
// Symbol is "selected" if it matches a portfolio/watchlist row the user explicitly selected with 's'
if (self.active_tab != .portfolio) return false;
if (self.states.portfolio.rows.items.len == 0) return false;
if (self.states.portfolio.cursor >= self.states.portfolio.rows.items.len) return false;
return std.mem.eql(u8, self.states.portfolio.rows.items[self.states.portfolio.cursor].symbol, self.symbol);
}
fn drawContent(self: *App, ctx: vaxis.vxfw.DrawContext, width: u16, height: u16) !vaxis.vxfw.Surface {
const th = self.theme;
const content_style = th.contentStyle();
const buf_size: usize = @as(usize, width) * height;
const buf = try ctx.arena.alloc(vaxis.Cell, buf_size);
@memset(buf, .{ .char = .{ .grapheme = " " }, .style = content_style });
if (self.mode == .help) {
try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildHelpStyledLines(ctx.arena));
} else if (self.mode == .account_picker or self.mode == .account_search) {
try tab_modules.portfolio.drawAccountPicker(&self.states.portfolio, self, ctx.arena, buf, width, height);
} else {
switch (self.active_tab) {
.portfolio => try self.drawPortfolioContent(ctx.arena, buf, width, height),
.quote => try self.drawQuoteContent(ctx, buf, width, height),
.performance => {
const lines = try self.buildPerfStyledLines(ctx.arena);
const start = @min(self.scroll_offset, if (lines.len > 0) lines.len - 1 else 0);
try self.drawStyledContent(ctx.arena, buf, width, height, lines[start..]);
},
.options => try self.drawOptionsContent(ctx.arena, buf, width, height),
.earnings => {
const lines = try self.buildEarningsStyledLines(ctx.arena);
const start = @min(self.scroll_offset, if (lines.len > 0) lines.len - 1 else 0);
try self.drawStyledContent(ctx.arena, buf, width, height, lines[start..]);
},
.analysis => {
const lines = try self.buildAnalysisStyledLines(ctx.arena);
const start = @min(self.scroll_offset, if (lines.len > 0) lines.len - 1 else 0);
try self.drawStyledContent(ctx.arena, buf, width, height, lines[start..]);
},
.history => {
const lines = try self.buildHistoryStyledLines(ctx.arena);
const start = @min(self.scroll_offset, if (lines.len > 0) lines.len - 1 else 0);
try self.drawStyledContent(ctx.arena, buf, width, height, lines[start..]);
},
.projections => try tab_modules.projections.drawContent(&self.states.projections, self, ctx, buf, width, height),
}
}
return .{ .size = .{ .width = width, .height = height }, .widget = self.widget(), .buffer = buf, .children = &.{} };
}
pub fn drawStyledContent(_: *App, _: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16, lines: []const StyledLine) !void {
for (lines, 0..) |line, row| {
if (row >= height) break;
// Fill row with style bg
for (0..width) |ci| {
buf[row * width + ci] = .{ .char = .{ .grapheme = " " }, .style = line.style };
}
// Grapheme-based rendering (for braille / multi-byte Unicode lines)
if (line.graphemes) |graphemes| {
const cell_styles = line.cell_styles;
for (0..@min(graphemes.len, width)) |ci| {
const s = if (cell_styles) |cs| cs[ci] else line.style;
buf[row * width + ci] = .{ .char = .{ .grapheme = graphemes[ci] }, .style = s };
}
} else {
// UTF-8 aware rendering: byte index and column index tracked separately
var col: usize = 0;
var bi: usize = 0;
while (bi < line.text.len and col < width) {
var s = line.style;
if (line.alt_style) |alt| {
if (col >= line.alt_start and col < line.alt_end) s = alt;
}
const byte = line.text[bi];
if (byte < 0x80) {
// ASCII: single byte, single column
buf[row * width + col] = .{ .char = .{ .grapheme = ascii_g[byte] }, .style = s };
bi += 1;
} else {
// Multi-byte UTF-8: determine sequence length
const seq_len: usize = if (byte >= 0xF0) 4 else if (byte >= 0xE0) 3 else if (byte >= 0xC0) 2 else 1;
const end = @min(bi + seq_len, line.text.len);
buf[row * width + col] = .{ .char = .{ .grapheme = line.text[bi..end] }, .style = s };
bi = end;
}
col += 1;
}
}
}
}
/// Render a prompt + live input buffer + blinking cursor + right-
/// aligned hint into the status-bar cell buffer. Shared between
/// `.symbol_input` and `.date_input` modes — only the prompt and
/// hint text differ.
fn renderInputPrompt(self: *App, buf: []vaxis.Cell, width: u16, prompt: []const u8, hint: []const u8) void {
const t = self.theme;
const prompt_style = t.inputStyle();
@memset(buf, .{ .char = .{ .grapheme = " " }, .style = prompt_style });
for (0..@min(prompt.len, width)) |i| {
buf[i] = .{ .char = .{ .grapheme = glyph(prompt[i]) }, .style = prompt_style };
}
const input = self.input_buf[0..self.input_len];
for (0..@min(input.len, @as(usize, width) -| prompt.len)) |i| {
buf[prompt.len + i] = .{ .char = .{ .grapheme = glyph(input[i]) }, .style = prompt_style };
}
const cursor_pos = prompt.len + self.input_len;
if (cursor_pos < width) {
var cursor_style = prompt_style;
cursor_style.blink = true;
buf[cursor_pos] = .{ .char = .{ .grapheme = "_" }, .style = cursor_style };
}
if (width > hint.len + cursor_pos + 2) {
const hint_start = width - hint.len;
const hint_style = t.inputHintStyle();
for (0..hint.len) |i| {
buf[hint_start + i] = .{ .char = .{ .grapheme = glyph(hint[i]) }, .style = hint_style };
}
}
}
fn drawStatusBar(self: *App, ctx: vaxis.vxfw.DrawContext, width: u16) !vaxis.vxfw.Surface {
const t = self.theme;
const buf = try ctx.arena.alloc(vaxis.Cell, width);
if (self.mode == .symbol_input) {
self.renderInputPrompt(buf, width, "Symbol: ", " Enter=confirm Esc=cancel ");
} else if (self.mode == .date_input) {
self.renderInputPrompt(buf, width, "As-of: ", " YYYY-MM-DD | 1M | live Enter=confirm ");
} else if (self.mode == .account_picker) {
const prompt_style = t.inputStyle();
@memset(buf, .{ .char = .{ .grapheme = " " }, .style = prompt_style });
const hint = " j/k=navigate Enter=select Esc=cancel Click=select ";
for (0..@min(hint.len, width)) |i| {
buf[i] = .{ .char = .{ .grapheme = glyph(hint[i]) }, .style = prompt_style };
}
} else {
const status_style = t.statusStyle();
@memset(buf, .{ .char = .{ .grapheme = " " }, .style = status_style });
// Show account filter indicator when active, appended to status message
if (self.states.portfolio.account_filter != null and self.active_tab == .portfolio) {
const af = self.states.portfolio.account_filter.?;
const msg = self.getStatus();
const filter_text = std.fmt.allocPrint(ctx.arena, "{s} [Account: {s}]", .{ msg, af }) catch msg;
for (0..@min(filter_text.len, width)) |i| {
buf[i] = .{ .char = .{ .grapheme = glyph(filter_text[i]) }, .style = status_style };
}
} else {
const msg = self.getStatus();
for (0..@min(msg.len, width)) |i| {
buf[i] = .{ .char = .{ .grapheme = glyph(msg[i]) }, .style = status_style };
}
}
}
return .{ .size = .{ .width = width, .height = 1 }, .widget = self.widget(), .buffer = buf, .children = &.{} };
}
// ── Portfolio content ─────────────────────────────────────────
fn drawPortfolioContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
return tab_modules.portfolio.drawContent(&self.states.portfolio, self, arena, buf, width, height);
}
// ── Options content (with cursor/scroll) ─────────────────────
fn drawOptionsContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
const styled_lines = try tab_modules.options.buildStyledLines(self, arena);
const start = @min(self.scroll_offset, if (styled_lines.len > 0) styled_lines.len - 1 else 0);
try self.drawStyledContent(arena, buf, width, height, styled_lines[start..]);
}
// ── Quote tab ────────────────────────────────────────────────
fn drawQuoteContent(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void {
return tab_modules.quote.drawContent(self, ctx, buf, width, height);
}
// ── Performance tab ──────────────────────────────────────────
fn buildPerfStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
return tab_modules.performance.buildStyledLines(self, arena);
}
// ── Earnings tab ─────────────────────────────────────────────
fn buildEarningsStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
return tab_modules.earnings.buildStyledLines(self, arena);
}
// ── Analysis tab ────────────────────────────────────────────
fn buildAnalysisStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
return tab_modules.analysis.buildStyledLines(self, arena);
}
fn buildHistoryStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
return tab_modules.history.buildStyledLines(&self.states.history, self, arena);
}
// ── Help ─────────────────────────────────────────────────────
fn buildHelpStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
const th = self.theme;
var lines: std.ArrayList(StyledLine) = .empty;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " zfin TUI -- Keybindings", .style = th.headerStyle() });
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
const actions = comptime std.enums.values(keybinds.Action);
const action_labels = [_][]const u8{
"Quit", "Refresh", "Previous tab", "Next tab",
"Tab 1", "Tab 2", "Tab 3", "Tab 4",
"Tab 5", "Tab 6", "Tab 7", "Scroll down",
"Scroll up", "Scroll to top", "Scroll to bottom", "Page down",
"Page up", "Select next", "Select prev", "Expand/collapse",
"Select symbol", "Change symbol (search)", "This help", "Reload portfolio from disk",
"Toggle all calls (options)", "Toggle all puts (options)", "Filter +/- 1 NTM", "Filter +/- 2 NTM",
"Filter +/- 3 NTM", "Filter +/- 4 NTM", "Filter +/- 5 NTM", "Filter +/- 6 NTM",
"Filter +/- 7 NTM", "Filter +/- 8 NTM", "Filter +/- 9 NTM", "Chart: next timeframe",
"Chart: prev timeframe", "History: cycle metric", "Sort: next column", "Sort: prev column",
"Sort: reverse order", "Account filter (portfolio)",
};
for (actions, 0..) |action, ai| {
var key_strs: [8][]const u8 = undefined;
var key_count: usize = 0;
for (self.keymap.bindings) |b| {
if (b.action == action and key_count < key_strs.len) {
var key_buf: [32]u8 = undefined;
if (keybinds.formatKeyCombo(b.key, &key_buf)) |s| {
key_strs[key_count] = try arena.dupe(u8, s);
key_count += 1;
}
}
}
if (key_count == 0) continue;
var combined_buf: [128]u8 = undefined;
var pos: usize = 0;
for (0..key_count) |ki| {
if (ki > 0) {
if (pos + 2 <= combined_buf.len) {
combined_buf[pos] = ',';
combined_buf[pos + 1] = ' ';
pos += 2;
}
}
const ks = key_strs[ki];
if (pos + ks.len <= combined_buf.len) {
@memcpy(combined_buf[pos..][0..ks.len], ks);
pos += ks.len;
}
}
const label_text = if (ai < action_labels.len) action_labels[ai] else @tagName(action);
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s}", .{ combined_buf[0..pos], label_text }), .style = th.contentStyle() });
}
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " Mouse: click tabs, scroll wheel, click rows", .style = th.mutedStyle() });
try lines.append(arena, .{ .text = " Config: ~/.config/zfin/keys.srf | theme.srf", .style = th.mutedStyle() });
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " Press any key to close.", .style = th.dimStyle() });
return lines.toOwnedSlice(arena);
}
// ── Tab navigation ───────────────────────────────────────────
fn nextTab(self: *App) void {
const idx = @intFromEnum(self.active_tab);
var next_idx = if (idx + 1 < tabs.len) idx + 1 else 0;
// Skip disabled tabs (earnings for ETFs, analysis without portfolio)
var tries: usize = 0;
while (self.isDisabled(tabs[next_idx]) and tries < tabs.len) : (tries += 1)
next_idx = if (next_idx + 1 < tabs.len) next_idx + 1 else 0;
self.active_tab = tabs[next_idx];
}
fn prevTab(self: *App) void {
const idx = @intFromEnum(self.active_tab);
var prev_idx = if (idx > 0) idx - 1 else tabs.len - 1;
// Skip disabled tabs (earnings for ETFs, analysis without portfolio)
var tries: usize = 0;
while (self.isDisabled(tabs[prev_idx]) and tries < tabs.len) : (tries += 1)
prev_idx = if (prev_idx > 0) prev_idx - 1 else tabs.len - 1;
self.active_tab = tabs[prev_idx];
}
};
// ── Utility functions ────────────────────────────────────────
pub fn renderBrailleToStyledLines(arena: std.mem.Allocator, lines: *std.ArrayList(StyledLine), data: []const zfin.Candle, th: theme.Theme) !void {
// Local shadows the `chart` module import; use a shorter name for
// the local BrailleChart handle.
var br = fmt.computeBrailleChart(arena, data, 60, 10, th.positive, th.negative) catch return;
// No deinit needed: arena handles cleanup
const bg = th.bg;
for (0..br.chart_height) |row| {
const graphemes = try arena.alloc([]const u8, br.n_cols + 12); // chart + padding + label
const styles = try arena.alloc(vaxis.Style, br.n_cols + 12);
var gpos: usize = 0;
// 2 leading spaces
graphemes[gpos] = " ";
styles[gpos] = .{ .fg = theme.Theme.vcolor(th.text_muted), .bg = theme.Theme.vcolor(bg) };
gpos += 1;
graphemes[gpos] = " ";
styles[gpos] = styles[0];
gpos += 1;
// Chart columns
for (0..br.n_cols) |col| {
const pattern = br.pattern(row, col);
graphemes[gpos] = fmt.brailleGlyph(pattern);
if (pattern != 0) {
styles[gpos] = .{ .fg = theme.Theme.vcolor(br.col_colors[col]), .bg = theme.Theme.vcolor(bg) };
} else {
styles[gpos] = .{ .fg = theme.Theme.vcolor(bg), .bg = theme.Theme.vcolor(bg) };
}
gpos += 1;
}
// Right-side price labels
if (row == 0) {
const lbl = try std.fmt.allocPrint(arena, " {s}", .{br.maxLabel()});
for (lbl) |ch| {
if (gpos < graphemes.len) {
graphemes[gpos] = glyph(ch);
styles[gpos] = .{ .fg = theme.Theme.vcolor(th.text_muted), .bg = theme.Theme.vcolor(bg) };
gpos += 1;
}
}
} else if (row == br.chart_height - 1) {
const lbl = try std.fmt.allocPrint(arena, " {s}", .{br.minLabel()});
for (lbl) |ch| {
if (gpos < graphemes.len) {
graphemes[gpos] = glyph(ch);
styles[gpos] = .{ .fg = theme.Theme.vcolor(th.text_muted), .bg = theme.Theme.vcolor(bg) };
gpos += 1;
}
}
}
try lines.append(arena, .{
.text = "",
.style = .{ .fg = theme.Theme.vcolor(th.text), .bg = theme.Theme.vcolor(bg) },
.graphemes = graphemes[0..gpos],
.cell_styles = styles[0..gpos],
});
}
// Date axis below chart
{
var start_buf: [8]u8 = undefined;
var end_buf: [8]u8 = undefined;
const start_label = br.fmtAxisDate(br.start_date, &start_buf);
const end_label = br.fmtAxisDate(br.end_date, &end_buf);
const muted_style = vaxis.Style{ .fg = theme.Theme.vcolor(th.text_muted), .bg = theme.Theme.vcolor(bg) };
const date_graphemes = try arena.alloc([]const u8, br.n_cols + 12);
const date_styles = try arena.alloc(vaxis.Style, br.n_cols + 12);
var dpos: usize = 0;
// 2 leading spaces
date_graphemes[dpos] = " ";
date_styles[dpos] = muted_style;
dpos += 1;
date_graphemes[dpos] = " ";
date_styles[dpos] = muted_style;
dpos += 1;
// Start date label
for (start_label) |ch| {
if (dpos < date_graphemes.len) {
date_graphemes[dpos] = glyph(ch);
date_styles[dpos] = muted_style;
dpos += 1;
}
}
// Gap between labels
const total_width = br.n_cols;
if (total_width > start_label.len + end_label.len) {
const gap = total_width - start_label.len - end_label.len;
for (0..gap) |_| {
if (dpos < date_graphemes.len) {
date_graphemes[dpos] = " ";
date_styles[dpos] = muted_style;
dpos += 1;
}
}
}
// End date label
for (end_label) |ch| {
if (dpos < date_graphemes.len) {
date_graphemes[dpos] = glyph(ch);
date_styles[dpos] = muted_style;
dpos += 1;
}
}
try lines.append(arena, .{
.text = "",
.style = .{ .fg = theme.Theme.vcolor(th.text), .bg = theme.Theme.vcolor(bg) },
.graphemes = date_graphemes[0..dpos],
.cell_styles = date_styles[0..dpos],
});
}
}
pub const loadWatchlist = cli.loadWatchlist;
pub const freeWatchlist = cli.freeWatchlist;
// Force test discovery for imported TUI sub-modules
comptime {
_ = keybinds;
_ = theme;
_ = tab_modules.portfolio;
_ = tab_modules.quote;
_ = tab_modules.performance;
_ = tab_modules.options;
_ = tab_modules.earnings;
_ = tab_modules.analysis;
_ = tab_modules.history;
_ = tab_modules.projections;
}
/// Entry point for the interactive TUI.
/// `args` contains only command-local tokens (everything after `interactive`).
pub fn run(
io: std.Io,
allocator: std.mem.Allocator,
config: zfin.Config,
global_portfolio_path: ?[]const u8,
global_watchlist_path: ?[]const u8,
args: []const []const u8,
today: zfin.Date,
) !void {
var portfolio_path: ?[]const u8 = global_portfolio_path;
const watchlist_path: ?[]const u8 = global_watchlist_path;
var symbol: []const u8 = "";
var symbol_upper_buf: [32]u8 = undefined;
var has_explicit_symbol = false;
var skip_watchlist = false;
var chart_config: chart.ChartConfig = .{};
var i: usize = 0;
while (i < args.len) : (i += 1) {
if (std.mem.eql(u8, args[i], "--default-keys")) {
try keybinds.printDefaults(io);
return;
} else if (std.mem.eql(u8, args[i], "--default-theme")) {
try theme.printDefaults(io);
return;
} else if (std.mem.eql(u8, args[i], "--symbol") or std.mem.eql(u8, args[i], "-s")) {
if (i + 1 < args.len) {
i += 1;
const len = @min(args[i].len, symbol_upper_buf.len);
_ = std.ascii.upperString(symbol_upper_buf[0..len], args[i][0..len]);
symbol = symbol_upper_buf[0..len];
has_explicit_symbol = true;
skip_watchlist = true;
}
} else if (std.mem.eql(u8, args[i], "--chart")) {
if (i + 1 < args.len) {
i += 1;
if (chart.ChartConfig.parse(args[i])) |cc| {
chart_config = cc;
}
}
} else if (args[i].len > 0 and args[i][0] != '-') {
const len = @min(args[i].len, symbol_upper_buf.len);
_ = std.ascii.upperString(symbol_upper_buf[0..len], args[i][0..len]);
symbol = symbol_upper_buf[0..len];
has_explicit_symbol = true;
}
}
var resolved_pf: ?zfin.Config.ResolvedPath = null;
defer if (resolved_pf) |r| r.deinit(allocator);
if (portfolio_path == null and !has_explicit_symbol) {
if (config.resolveUserFile(io, allocator, zfin.Config.default_portfolio_filename)) |r| {
resolved_pf = r;
portfolio_path = r.path;
}
}
var keymap = blk: {
const home_opt = if (config.environ_map) |em| em.get("HOME") else null;
const home = home_opt orelse break :blk keybinds.defaults();
const keys_path = std.fs.path.join(allocator, &.{ home, ".config", "zfin", "keys.srf" }) catch
break :blk keybinds.defaults();
defer allocator.free(keys_path);
break :blk keybinds.loadFromFile(io, allocator, keys_path) orelse keybinds.defaults();
};
defer keymap.deinit();
const loaded_theme = blk: {
const home_opt = if (config.environ_map) |em| em.get("HOME") else null;
const home = home_opt orelse break :blk theme.default_theme;
const theme_path = std.fs.path.join(allocator, &.{ home, ".config", "zfin", "theme.srf" }) catch
break :blk theme.default_theme;
defer allocator.free(theme_path);
break :blk theme.loadFromFile(io, allocator, theme_path) orelse theme.default_theme;
};
var svc = try allocator.create(zfin.DataService);
defer allocator.destroy(svc);
svc.* = zfin.DataService.init(io, allocator, config);
defer svc.deinit();
var app_inst = try allocator.create(App);
defer allocator.destroy(app_inst);
app_inst.* = .{
.allocator = allocator,
.io = io,
.today = today,
.config = config,
.svc = svc,
.keymap = keymap,
.theme = loaded_theme,
.portfolio_path = portfolio_path,
.symbol = symbol,
.has_explicit_symbol = has_explicit_symbol,
.chart_config = chart_config,
};
// History tab requires explicit init (allocator-backed hash map);
// other tabs use field defaults. The corresponding deinit lives
// in `App.deinitData`.
try tab_modules.history.tab.init(&app_inst.states.history, app_inst);
if (portfolio_path) |path| {
const file_data = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(10 * 1024 * 1024)) catch null;
if (file_data) |d| {
defer allocator.free(d);
if (zfin.cache.deserializePortfolio(allocator, d)) |pf| {
app_inst.portfolio.file = pf;
} else |_| {}
}
}
var resolved_wl: ?zfin.Config.ResolvedPath = null;
defer if (resolved_wl) |r| r.deinit(allocator);
if (!skip_watchlist) {
const wl_path = watchlist_path orelse blk: {
if (config.resolveUserFile(io, allocator, "watchlist.srf")) |r| {
resolved_wl = r;
break :blk @as(?[]const u8, r.path);
}
break :blk null;
};
if (wl_path) |path| {
app_inst.watchlist = loadWatchlist(io, allocator, path);
app_inst.watchlist_path = path;
}
}
if (has_explicit_symbol and symbol.len > 0) {
app_inst.active_tab = .quote;
}
// Pre-fetch portfolio prices before TUI starts, with stderr progress.
// This runs while the terminal is still in normal mode so output is visible.
if (app_inst.portfolio.file) |pf| {
const syms = pf.stockSymbols(allocator) catch null;
defer if (syms) |s| allocator.free(s);
// Collect watchlist symbols
var watch_syms: std.ArrayList([]const u8) = .empty;
defer watch_syms.deinit(allocator);
{
var seen = std.StringHashMap(void).init(allocator);
defer seen.deinit();
if (syms) |ss| for (ss) |s| seen.put(s, {}) catch {};
if (app_inst.watchlist) |wl| {
for (wl) |sym_w| {
if (!seen.contains(sym_w)) {
seen.put(sym_w, {}) catch {};
watch_syms.append(allocator, sym_w) catch {};
}
}
}
for (pf.lots) |lot| {
if (lot.security_type == .watch and !seen.contains(lot.priceSymbol())) {
seen.put(lot.priceSymbol(), {}) catch {};
watch_syms.append(allocator, lot.priceSymbol()) catch {};
}
}
}
const stock_count = if (syms) |ss| ss.len else 0;
const total_count = stock_count + watch_syms.items.len;
if (total_count > 0) {
// Use consolidated parallel loader
const load_result = cli.loadPortfolioPrices(
io,
svc,
syms,
watch_syms.items,
false, // force_refresh
true, // color
);
app_inst.states.portfolio.prefetched_prices = load_result.prices;
}
// Eagerly compute PortfolioData so the history-tab's live
// pseudo-row + compare-to-live-now works from first render,
// without requiring the user to visit the portfolio tab
// first. Cheap (pure compute + cache reads) once prices are
// already in hand.
if (app_inst.portfolio.file != null) {
tab_modules.portfolio.loadPortfolioData(&app_inst.states.portfolio, app_inst);
}
}
defer freeWatchlist(allocator, app_inst.watchlist);
defer app_inst.deinitData();
{
// vaxis 0.16 requires a pre-allocated app buffer, an Io, and
// an env map. The buffer must outlive vx_app.
var vx_app_buf: [4096]u8 = undefined;
const environ_map = config.environ_map orelse return error.MissingEnvironMap;
var vx_app = try vaxis.vxfw.App.init(io, allocator, @constCast(environ_map), &vx_app_buf);
defer vx_app.deinit();
app_inst.vx_app = &vx_app;
defer app_inst.vx_app = null;
defer {
// Free any chart image before vaxis is torn down
if (app_inst.states.quote.chart.image_id) |id| {
vx_app.vx.freeImage(vx_app.tty.writer(), id);
app_inst.states.quote.chart.image_id = null;
}
if (app_inst.states.projections.image_id) |id| {
vx_app.vx.freeImage(vx_app.tty.writer(), id);
app_inst.states.projections.image_id = null;
}
}
try vx_app.run(app_inst.widget(), .{});
}
}
// ── Tests ─────────────────────────────────────────────────────────────
const testing = std.testing;
test "colLabel plain left-aligned" {
var buf: [32]u8 = undefined;
const result = colLabel(&buf, "Name", 10, true, null);
try testing.expectEqualStrings("Name ", result);
try testing.expectEqual(@as(usize, 10), result.len);
}
test "colLabel plain right-aligned" {
var buf: [32]u8 = undefined;
const result = colLabel(&buf, "Price", 10, false, null);
try testing.expectEqualStrings(" Price", result);
}
test "colLabel with indicator left-aligned" {
var buf: [64]u8 = undefined;
const result = colLabel(&buf, "Name", 10, true, "\xe2\x96\xb2"); // ▲ = 3 bytes
// Indicator + text + padding. Display width is 10, byte length is 10 - 1 + 3 = 12
try testing.expectEqual(@as(usize, 12), result.len);
try testing.expect(std.mem.startsWith(u8, result, "\xe2\x96\xb2")); // starts with ▲
try testing.expect(std.mem.indexOf(u8, result, "Name") != null);
}
test "colLabel with indicator right-aligned" {
var buf: [64]u8 = undefined;
const result = colLabel(&buf, "Price", 10, false, "\xe2\x96\xbc"); // ▼
try testing.expectEqual(@as(usize, 12), result.len);
try testing.expect(std.mem.endsWith(u8, result, "Price"));
}
test "glyph ASCII returns single-char slice" {
try testing.expectEqualStrings("A", glyph('A'));
try testing.expectEqualStrings(" ", glyph(' '));
try testing.expectEqualStrings("0", glyph('0'));
}
test "glyph non-ASCII returns space" {
try testing.expectEqualStrings(" ", glyph(200));
}
test "PortfolioSortField next/prev" {
// next from first field
try testing.expectEqual(PortfolioSortField.shares, PortfolioSortField.symbol.next().?);
// next from last field returns null
try testing.expectEqual(@as(?PortfolioSortField, null), PortfolioSortField.account.next());
// prev from first returns null
try testing.expectEqual(@as(?PortfolioSortField, null), PortfolioSortField.symbol.prev());
// prev from last
try testing.expectEqual(PortfolioSortField.weight, PortfolioSortField.account.prev().?);
}
test "PortfolioSortField label" {
try testing.expectEqualStrings("Symbol", PortfolioSortField.symbol.label());
try testing.expectEqualStrings("Market Value", PortfolioSortField.market_value.label());
}
test "SortDirection flip and indicator" {
try testing.expectEqual(SortDirection.desc, SortDirection.asc.flip());
try testing.expectEqual(SortDirection.asc, SortDirection.desc.flip());
try testing.expectEqualStrings("\xe2\x96\xb2", SortDirection.asc.indicator()); // ▲
try testing.expectEqualStrings("\xe2\x96\xbc", SortDirection.desc.indicator()); // ▼
}
test "Tab label" {
try testing.expectEqualStrings(" 1:Portfolio ", tabLabel(.portfolio));
try testing.expectEqualStrings(" 6:Analysis ", tabLabel(.analysis));
}