zfin/src/tui.zig

2827 lines
120 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");
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,
};
/// Pre-resolved row in the help overlay: a key string (possibly
/// comma-joined for multiple bindings) plus its label. Used by
/// `buildHelpLines` so the renderer is a pure function over already-
/// resolved data and doesn't need access to the keymap or tab modules.
pub const HelpRow = struct {
keys: []const u8,
label: []const u8,
};
/// One `key label` fragment of the dynamic status-hint line. Used by
/// `formatStatusHint` to compose the full hint as ` | `-joined fragments.
pub const StatusHintFragment = struct {
key: []const u8,
label: []const u8,
};
/// Format the dynamic default status hint from pre-resolved key /
/// label fragments. Each fragment renders as `key label`; fragments
/// are joined with ` | `. Pure function — no App access.
pub fn formatStatusHint(
arena: std.mem.Allocator,
fragments: []const StatusHintFragment,
) ![]const u8 {
if (fragments.len == 0) return "";
var pieces: std.ArrayListUnmanaged([]const u8) = .empty;
for (fragments) |f| {
try pieces.append(arena, try std.fmt.allocPrint(arena, "{s} {s}", .{ f.key, f.label }));
}
return std.mem.join(arena, " | ", pieces.items);
}
/// Pre-resolved data passed to `buildHelpLines`. Comprises the
/// global section's rows, the active tab section's rows, and the
/// active tab's display name (without the registry-position prefix).
pub const HelpData = struct {
globals: []const HelpRow,
tab_rows: []const HelpRow,
active_tab_name: []const u8,
};
/// Render the help overlay's styled lines from pre-resolved data.
/// Pure function — no App access, no keymap lookup. Easy to test
/// with fixture rows.
pub fn buildHelpLines(
arena: std.mem.Allocator,
th: theme.Theme,
data: HelpData,
) ![]const StyledLine {
var lines: std.ArrayListUnmanaged(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() });
try lines.append(arena, .{ .text = " Global", .style = th.headerStyle() });
for (data.globals) |row| {
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {s:<20} {s}", .{ row.keys, row.label }),
.style = th.contentStyle(),
});
}
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " Active Tab: {s}", .{data.active_tab_name}),
.style = th.headerStyle(),
});
if (data.tab_rows.len == 0) {
try lines.append(arena, .{
.text = " (no tab-local actions)",
.style = th.mutedStyle(),
});
} else {
for (data.tab_rows) |row| {
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {s:<20} {s}", .{ row.keys, row.label }),
.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-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);
}
}
// Comptime check: tabs must not bind keys that are already bound
// in the global keymap. Globals always win (the dispatcher reaches
// `dispatchTabLocalKey` only when `keymap.matchAction` returns
// null), so a tab-local binding for a globally-bound key is dead
// code. Reject it loudly at compile time.
//
// This lives in `tui.zig` rather than `tab_framework.zig` to keep
// the framework decoupled from the global keymap; here both are
// already in scope.
comptime {
@setEvalBranchQuota(20000);
for (std.meta.fields(@TypeOf(tab_modules))) |field| {
const Module = @field(tab_modules, field.name);
for (Module.tab.default_bindings) |binding| {
for (keybinds.global_default_bindings) |global| {
if (binding.key.codepoint == global.key.codepoint and
std.meta.eql(binding.key.mods, global.key.mods))
{
@compileError("Tab `" ++ field.name ++ "` binds a key in `default_bindings` " ++
"that is already bound in the global keymap (`keybinds.zig`). " ++
"Tab-local bindings cannot override global keys; pick a different key " ++
"or remove the global binding. Conflicting tab action: ." ++
@tagName(binding.action) ++ "; conflicting global action: ." ++
@tagName(global.action));
}
}
}
}
}
/// 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;
}
/// Tab-local keybind dispatch. Walks the active tab's
/// keybindings looking for a key match. If the user's keys.srf
/// has a `scope::<tab>` override for the active tab, that
/// list is consulted (action names resolved against the tab's
/// local `Action` enum). Otherwise the tab module's
/// `default_bindings` is used. On match, invokes
/// `tab.handleAction(state, app, action)` and returns `true`.
/// Returns `false` if no binding matched.
///
/// Called as a fallback AFTER the global keymap; under the
/// "globals always win" rule, tabs are forbidden (by validator
/// at comptime, by user-config check at runtime) from binding
/// keys that are already global, so a key reaching here is by
/// definition not a global keybind.
///
/// Adding a tab-local action: declare it in the tab's `Action`
/// enum, bind it in `default_bindings`, and `handleAction` runs
/// it. No edit to `tui.zig` required.
fn dispatchTabLocalKey(self: *App, key: vaxis.Key) 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);
const state_ptr = &@field(self.states, field.name);
// Prefer user overrides if present for this tab.
if (self.keymap.tabOverridesFor(field.name)) |overrides| {
for (overrides) |ovr| {
if (key.matches(ovr.key.codepoint, ovr.key.mods)) {
// Resolve action_name against the tab's Action enum.
const ActionT = Module.Action;
inline for (std.meta.fields(ActionT)) |af| {
if (std.mem.eql(u8, af.name, ovr.action_name)) {
const action: ActionT = @enumFromInt(af.value);
Module.tab.handleAction(state_ptr, self, action);
return true;
}
}
// Action name didn't resolve; treat as
// unbound (silent — keys.srf parsing
// already validated the action exists,
// future work).
return false;
}
}
return false;
}
// No user overrides — use the tab's default_bindings.
for (Module.tab.default_bindings) |binding| {
if (key.matches(binding.key.codepoint, binding.key.mods)) {
Module.tab.handleAction(state_ptr, self, binding.action);
return true;
}
}
return false;
}
}
return false;
}
/// Return all formatted key strings bound to the given global
/// `action`. Allocated in `arena`. Empty slice if no binding
/// exists. Order matches the keymap's binding order.
///
/// Used by the help overlay and dynamic status hints to render
/// "actual current key" rather than hardcoded literals — so that
/// rebinding a key in `keys.srf` updates the displayed name.
pub fn keysForGlobal(self: *const App, arena: std.mem.Allocator, action: keybinds.Action) ![][]const u8 {
var out: std.ArrayList([]const u8) = .empty;
for (self.keymap.bindings) |b| {
if (b.action != action) continue;
var key_buf: [32]u8 = undefined;
const s = keybinds.formatKeyCombo(b.key, &key_buf) orelse continue;
try out.append(arena, try arena.dupe(u8, s));
}
return out.toOwnedSlice(arena);
}
/// Return all formatted key strings bound to the named tab-local
/// action in the given tab's keymap. Looks up user overrides
/// first (`tabOverridesFor(scope)`), falling back to the tab
/// module's `default_bindings`. The action name is matched against
/// variant names of the tab's `Action` enum.
///
/// `scope` is the tab tag name (e.g. `"options"`, `"history"`)
/// matching the `tab_modules` registry. Comptime so we can resolve
/// the tab's Action enum type.
pub fn keysForTabAction(
self: *const App,
arena: std.mem.Allocator,
comptime scope: []const u8,
action_tag_name: []const u8,
) ![][]const u8 {
const Module = @field(tab_modules, scope);
var out: std.ArrayList([]const u8) = .empty;
// Prefer user overrides for this scope when present.
if (self.keymap.tabOverridesFor(scope)) |overrides| {
for (overrides) |ovr| {
if (!std.mem.eql(u8, ovr.action_name, action_tag_name)) continue;
var key_buf: [32]u8 = undefined;
const s = keybinds.formatKeyCombo(ovr.key, &key_buf) orelse continue;
try out.append(arena, try arena.dupe(u8, s));
}
return out.toOwnedSlice(arena);
}
// No overrides — read from the tab's default_bindings.
for (Module.tab.default_bindings) |binding| {
if (!std.mem.eql(u8, @tagName(binding.action), action_tag_name)) continue;
var key_buf: [32]u8 = undefined;
const s = keybinds.formatKeyCombo(binding.key, &key_buf) orelse continue;
try out.append(arena, try arena.dupe(u8, s));
}
return out.toOwnedSlice(arena);
}
/// Convenience: like `keysForTabAction` but resolves to whichever
/// tab is currently active. Comptime-walks `tab_modules` to find
/// the matching scope.
fn keysForActiveTabAction(
self: *const App,
arena: std.mem.Allocator,
action_tag_name: []const u8,
) ![][]const u8 {
inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
if (std.mem.eql(u8, field.name, @tagName(self.active_tab))) {
return self.keysForTabAction(arena, field.name, action_tag_name);
}
}
return &.{};
}
/// 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();
}
const action = self.keymap.matchAction(key) orelse {
// No global binding matched. Fall back to tab-local
// dispatch — the active tab may bind this key in its
// `default_bindings`. Globals win (no overlap allowed,
// enforced by validator + user-config check), so
// reaching here means the key is purely tab-local.
if (self.dispatchTabLocalKey(key)) return ctx.consumeAndRedraw();
return;
};
switch (action) {
.quit => {
ctx.quit = true;
},
.symbol_input => {
self.mode = .symbol_input;
self.input_len = 0;
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();
},
.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();
},
}
}
/// 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;
}
/// Returns the current status message. When no message has been
/// set, builds a dynamic default hint composed from a small set
/// of always-shown global keys plus the active tab's
/// `status_hints`. Allocated in `arena` for the dynamic default;
/// the user-set buffer is returned by reference.
fn getStatus(self: *App, arena: std.mem.Allocator) []const u8 {
if (self.status_len > 0) return self.status_msg[0..self.status_len];
return self.buildDefaultStatusHint(arena) catch
"h/l tabs | j/k select | / symbol | ? help";
}
/// Build the dynamic default status hint: a small set of always-
/// shown global keys (tab nav, cursor, symbol input, help) plus
/// the active tab's `status_hints` actions resolved against its
/// current bindings. Each fragment is `key label`.
fn buildDefaultStatusHint(self: *App, arena: std.mem.Allocator) ![]const u8 {
var fragments: std.ArrayListUnmanaged(StatusHintFragment) = .empty;
// Always-shown globals. Each fragment uses the FIRST bound
// key for the action; full lists go to the help overlay.
const globals = [_]struct {
action: keybinds.Action,
label: []const u8,
}{
.{ .action = .prev_tab, .label = "tabs" },
.{ .action = .select_next, .label = "select" },
.{ .action = .symbol_input, .label = "symbol" },
.{ .action = .help, .label = "help" },
};
for (globals) |g| {
const keys = try self.keysForGlobal(arena, g.action);
if (keys.len == 0) continue;
try fragments.append(arena, .{ .key = keys[0], .label = g.label });
}
// Active tab's status_hints — comptime walk to get the right
// Action enum + label table.
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);
for (Module.tab.status_hints) |hint_action| {
const action_name = @tagName(hint_action);
const keys = try self.keysForTabAction(arena, field.name, action_name);
if (keys.len == 0) continue;
const label = Module.tab.action_labels.get(hint_action);
if (label.len == 0) continue;
try fragments.append(arena, .{ .key = keys[0], .label = label });
}
}
}
return formatStatusHint(arena, fragments.items);
}
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(ctx.arena);
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(ctx.arena);
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 {
// Resolve the help-overlay data upfront (key strings + labels)
// so the renderer is a pure function over the pre-resolved data.
// Active-tab section requires a comptime walk to dispatch on
// `self.active_tab`'s local Action enum; the data we collect
// is type-erased to `[]const HelpRow`.
var globals: std.ArrayListUnmanaged(HelpRow) = .empty;
const global_actions = comptime std.enums.values(keybinds.Action);
for (global_actions) |action| {
const keys = try self.keysForGlobal(arena, action);
if (keys.len == 0) continue;
try globals.append(arena, .{
.keys = try std.mem.join(arena, ", ", keys),
.label = keybinds.action_labels.get(action),
});
}
var tab_rows: std.ArrayListUnmanaged(HelpRow) = .empty;
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);
const tab_actions = comptime std.enums.values(Module.Action);
inline for (tab_actions) |action| {
const action_name = @tagName(action);
const label = Module.tab.action_labels.get(action);
if (label.len > 0) {
const keys = try self.keysForTabAction(arena, field.name, action_name);
const keys_str = if (keys.len == 0)
try arena.dupe(u8, "(unbound)")
else
try std.mem.join(arena, ", ", keys);
try tab_rows.append(arena, .{
.keys = keys_str,
.label = label,
});
}
}
}
}
// Active tab name (without the registry-position prefix) for
// the section header.
const active_tab_label = tabLabel(self.active_tab);
const trimmed = std.mem.trim(u8, active_tab_label, " ");
const colon_pos = std.mem.indexOfScalar(u8, trimmed, ':') orelse 0;
const active_tab_name = if (colon_pos > 0) trimmed[colon_pos + 1 ..] else trimmed;
return buildHelpLines(arena, self.theme, .{
.globals = globals.items,
.tab_rows = tab_rows.items,
.active_tab_name = active_tab_name,
});
}
// ── 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;
}
/// Write the full default keymap to `out` in keys.srf format,
/// covering both the global section and each tab's local bindings.
///
/// Output shape:
///
/// ```
/// #!srfv1
/// # ... preamble + format docs ...
///
/// # ── Global ──
/// action::quit,key::q
/// ...
///
/// # ── Tab: portfolio ──
/// scope::portfolio,action::toggle_account_picker,key::a
/// ...
/// ```
///
/// Per-tab sections are emitted in `tab_modules` declaration order
/// and only when the tab has at least one default binding (skipping
/// empty sections keeps the file compact).
///
/// Caller is responsible for flushing.
fn writeDefaultKeys(out: *std.Io.Writer) !void {
try keybinds.printDefaultsHeader(out);
try keybinds.printSectionHeader(out, "Global");
try keybinds.printGlobalBindings(out);
inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
const Module = @field(tab_modules, field.name);
if (Module.tab.default_bindings.len == 0) continue;
const heading = "Tab: " ++ field.name;
try keybinds.printSectionHeader(out, heading);
for (Module.tab.default_bindings) |binding| {
try keybinds.printScopedBinding(
out,
field.name,
@tagName(binding.action),
binding.key,
);
}
}
}
/// CLI entry point for `--default-keys`. Writes the full default
/// keymap to stdout. See `writeDefaultKeys` for output format.
fn printDefaultKeys(io: std.Io) !void {
var buf: [4096]u8 = undefined;
var writer = std.Io.File.stdout().writer(io, &buf);
const out = &writer.interface;
try writeDefaultKeys(out);
try out.flush();
}
/// 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 printDefaultKeys(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);
switch (keybinds.loadFromFileChecked(io, allocator, keys_path)) {
.keymap => |km| break :blk km,
.fallback => break :blk keybinds.defaults(),
.err => |e| switch (e) {
error.TabBindingShadowsGlobal => {
// User keys.srf has a `scope::<tab>` record whose
// key is also bound globally. Globals always win,
// so the scoped binding would be dead. Refuse to
// start so the user knows their config is broken.
var stderr_buf: [4096]u8 = undefined;
var stderr_writer = std.Io.File.stderr().writer(io, &stderr_buf);
try stderr_writer.interface.print(
"zfin: keys.srf has a tab-scoped binding whose key is already " ++
"bound in the global keymap. Tab-local bindings cannot override " ++
"global keys.\n Edit {s} to remove the conflict, or remove the " ++
"global binding.\n",
.{keys_path},
);
try stderr_writer.interface.flush();
return error.KeyBindingConflict;
},
},
}
};
defer keymap.deinit();
// Surface per-record parse warnings (unknown action, malformed
// key, etc.) to stderr. Non-fatal — the keymap is otherwise
// usable; user just sees that some lines didn't take effect.
if (keymap.warnings.len > 0) {
var stderr_buf: [4096]u8 = undefined;
var stderr_writer = std.Io.File.stderr().writer(io, &stderr_buf);
for (keymap.warnings) |w| {
try stderr_writer.interface.print("zfin: {s}\n", .{w});
}
try stderr_writer.interface.flush();
}
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));
}
test "buildHelpLines: header, global section, active-tab section, footer" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const data: HelpData = .{
.globals = &.{
.{ .keys = "q, ctrl+c", .label = "Quit" },
.{ .keys = "j", .label = "Select next" },
},
.tab_rows = &.{
.{ .keys = "enter", .label = "Expand position" },
.{ .keys = "s, space", .label = "Select symbol" },
},
.active_tab_name = "Portfolio",
};
const lines = try buildHelpLines(arena, theme.default_theme, data);
var all: std.ArrayListUnmanaged(u8) = .empty;
for (lines) |l| {
try all.appendSlice(arena, l.text);
try all.append(arena, '\n');
}
const text = all.items;
// Title + section headers.
try testing.expect(std.mem.indexOf(u8, text, "zfin TUI -- Keybindings") != null);
try testing.expect(std.mem.indexOf(u8, text, " Global") != null);
try testing.expect(std.mem.indexOf(u8, text, " Active Tab: Portfolio") != null);
// Global rows.
try testing.expect(std.mem.indexOf(u8, text, "q, ctrl+c") != null);
try testing.expect(std.mem.indexOf(u8, text, "Quit") != null);
try testing.expect(std.mem.indexOf(u8, text, "Select next") != null);
// Tab rows.
try testing.expect(std.mem.indexOf(u8, text, "Expand position") != null);
try testing.expect(std.mem.indexOf(u8, text, "s, space") != null);
try testing.expect(std.mem.indexOf(u8, text, "Select symbol") != null);
// Footer.
try testing.expect(std.mem.indexOf(u8, text, "Mouse: click tabs") != null);
try testing.expect(std.mem.indexOf(u8, text, "~/.config/zfin/keys.srf") != null);
try testing.expect(std.mem.indexOf(u8, text, "Press any key to close") != null);
}
test "buildHelpLines: empty tab_rows shows '(no tab-local actions)'" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const data: HelpData = .{
.globals = &.{.{ .keys = "q", .label = "Quit" }},
.tab_rows = &.{},
.active_tab_name = "Earnings",
};
const lines = try buildHelpLines(arena, theme.default_theme, data);
var all: std.ArrayListUnmanaged(u8) = .empty;
for (lines) |l| {
try all.appendSlice(arena, l.text);
try all.append(arena, '\n');
}
const text = all.items;
try testing.expect(std.mem.indexOf(u8, text, "Active Tab: Earnings") != null);
try testing.expect(std.mem.indexOf(u8, text, "(no tab-local actions)") != null);
}
test "buildHelpLines: empty globals still renders title and section headers" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const data: HelpData = .{
.globals = &.{},
.tab_rows = &.{.{ .keys = "x", .label = "Do thing" }},
.active_tab_name = "Quote",
};
const lines = try buildHelpLines(arena, theme.default_theme, data);
var all: std.ArrayListUnmanaged(u8) = .empty;
for (lines) |l| {
try all.appendSlice(arena, l.text);
try all.append(arena, '\n');
}
const text = all.items;
try testing.expect(std.mem.indexOf(u8, text, " Global") != null);
try testing.expect(std.mem.indexOf(u8, text, "Active Tab: Quote") != null);
try testing.expect(std.mem.indexOf(u8, text, "Do thing") != null);
}
test "formatStatusHint: joins fragments with ' | '" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const fragments = [_]StatusHintFragment{
.{ .key = "h", .label = "tabs" },
.{ .key = "j", .label = "select" },
.{ .key = "/", .label = "symbol" },
.{ .key = "?", .label = "help" },
};
const out = try formatStatusHint(arena, &fragments);
try testing.expectEqualStrings("h tabs | j select | / symbol | ? help", out);
}
test "formatStatusHint: empty fragments returns empty string" {
const out = try formatStatusHint(testing.allocator, &.{});
try testing.expectEqualStrings("", out);
}
test "formatStatusHint: single fragment has no separator" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const fragments = [_]StatusHintFragment{
.{ .key = "ctrl+s", .label = "save" },
};
const out = try formatStatusHint(arena, &fragments);
try testing.expectEqualStrings("ctrl+s save", out);
}
test "writeDefaultKeys: includes preamble, global section, and per-tab sections" {
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try writeDefaultKeys(&aw.writer);
const out = aw.written();
// Preamble.
try testing.expect(std.mem.indexOf(u8, out, "#!srfv1") != null);
try testing.expect(std.mem.indexOf(u8, out, "Regenerate: zfin interactive --default-keys") != null);
// Global section header + at least one global binding (un-scoped).
try testing.expect(std.mem.indexOf(u8, out, "# ── Global ──") != null);
try testing.expect(std.mem.indexOf(u8, out, "action::quit,key::q") != null);
// At least one tab section appears, and bindings inside it carry
// their `scope::<tab>,` prefix (the user-edit format).
try testing.expect(std.mem.indexOf(u8, out, "# ── Tab: ") != null);
try testing.expect(std.mem.indexOf(u8, out, "scope::") != null);
}
test "writeDefaultKeys: every registered tab with default_bindings has a section" {
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try writeDefaultKeys(&aw.writer);
const out = aw.written();
inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
const Module = @field(tab_modules, field.name);
if (Module.tab.default_bindings.len == 0) continue;
const heading = "# ── Tab: " ++ field.name ++ " ──";
if (std.mem.indexOf(u8, out, heading) == null) {
std.debug.print("missing tab section: {s}\n", .{heading});
return error.MissingTabSection;
}
// And every binding for that tab must show up as a `scope::<tab>,action::<name>` line.
inline for (Module.tab.default_bindings) |binding| {
const needle = "scope::" ++ field.name ++ ",action::" ++ @tagName(binding.action);
if (std.mem.indexOf(u8, out, needle) == null) {
std.debug.print("missing binding line: {s}\n", .{needle});
return error.MissingBindingLine;
}
}
}
}
test "writeDefaultKeys: tab sections appear in tab_modules declaration order" {
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try writeDefaultKeys(&aw.writer);
const out = aw.written();
var prev_pos: usize = 0;
inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
const Module = @field(tab_modules, field.name);
if (Module.tab.default_bindings.len == 0) continue;
const heading = "# ── Tab: " ++ field.name ++ " ──";
const pos = std.mem.indexOf(u8, out, heading) orelse return error.MissingTabSection;
try testing.expect(pos >= prev_pos);
prev_pos = pos;
}
}