zfin/src/tui.zig

2574 lines
111 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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");
const input_buffer = @import("tui/input_buffer.zig");
/// Single source of truth for tab modules. Each entry is the
/// imported tab module; the field name is the tab's tag. The
/// `Tab` enum, `TabStates`, `tab_labels`, and the `tabs` slice
/// are all derived from this registry at comptime — adding a
/// new tab is a single edit (append a field here, declare the
/// tab's `tab` namespace + `State`, and everything else flows
/// from `pub const label` on the tab module).
const tab_modules = .{
.portfolio = @import("tui/portfolio_tab.zig"),
.analysis = @import("tui/analysis_tab.zig"),
.projections = @import("tui/projections_tab.zig"),
.history = @import("tui/history_tab.zig"),
.quote = @import("tui/quote_tab.zig"),
.performance = @import("tui/performance_tab.zig"),
.earnings = @import("tui/earnings_tab.zig"),
.options = @import("tui/options_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.meta.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);
/// Truly global UI modes layered over any tab. Tab-internal
/// modal sub-states (e.g. portfolio's account picker,
/// projections' as-of date input) live in the tab's own
/// `State.modal`, not here.
pub const InputMode = enum {
/// No global mode active.
normal,
/// Symbol-input prompt (any tab can trigger via the
/// `symbol_input` action).
symbol_input,
/// Help overlay.
help,
};
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);
}
/// 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.meta.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
/// `App.ensurePortfolioDataLoaded`; null when no symbols have
/// cached candles.
latest_quote_date: ?zfin.Date = null,
/// Prices fetched before the TUI started (with stderr
/// progress). Consumed by the first
/// `App.ensurePortfolioDataLoaded` call to skip redundant
/// network round-trips on startup. Owned here; freed after
/// first consumption.
prefetched_prices: ?std.StringHashMap(f64) = 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.prefetched_prices) |*pp| pp.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. Each tab
/// owns its UI state under `app.states.<tab>` — the field
/// name matches the `tab_modules` registry tag.
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 state lives in
// `self.states.portfolio` — see portfolio_tab.zig. The picker
// is fully tab-internal: opened/closed via
// `state.modal` and routed through portfolio's own
// `handleKey` / `handleMouse` / `drawContent` /
// `statusOverride` hooks. App.Mode does NOT carry picker
// variants.
// 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| {
// Tab-level pre-empt. The active tab gets first
// crack at every key (when it declares the hook).
// Used for tab-internal modals (account picker on
// portfolio, date input on projections, etc) that
// need to swallow keys before global keymap
// matching runs — otherwise typing `r` while a
// modal is open would refresh, which would be
// surprising.
//
// Tabs that don't declare `handleKey` skip this.
// Tabs that declare it but aren't currently in a
// modal sub-state should return `false` so
// dispatch falls through to the normal path.
if (self.dispatchBool("handleKey", .{key})) {
return ctx.consumeAndRedraw();
}
if (self.mode == .symbol_input) {
return self.handleInputKey(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 {
// Active-tab dispatch first. Tabs return `true` to
// consume; on `false` we fall through to App-level
// handling (tab-bar clicks, viewport scroll). This gives
// tab-internal modals (e.g. portfolio's account picker)
// first crack at every mouse event so they can swallow
// tab-bar clicks and prevent tab switching while modal.
//
// We probe `statusOverride` first to detect "tab is in a
// modal sub-state" — when it returns non-null, the
// tab gets the FULL mouse stream (including row 0).
// Otherwise (normal mode), we apply chrome ownership:
// row 0 is the tab bar, so non-modal tabs only see
// content-region events. This prevents
// `mouse.row + scroll_offset` ambiguity when a tab's
// handleMouse derives a content row from the raw mouse
// row — row 0 would otherwise look like
// `scroll_offset` rows of header, which can mis-read as
// a header/sort click.
const tab_is_modal = self.activeTabStatusOverride() != null;
if (tab_is_modal or mouse.row > 0) {
if (self.dispatchBool("handleMouse", .{mouse})) {
return ctx.consumeAndRedraw();
}
}
if (tab_is_modal) {
// Modal didn't consume (it always should — see
// contract above), but be defensive: still swallow
// the event so tab-bar clicks etc don't fire.
return ctx.consumeAndRedraw();
}
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;
}
}
// Content-region clicks already went through
// `dispatchBool("handleMouse")` above when
// `mouse.row > 0`. Reaching here means the tab
// declined to consume — nothing left to do.
},
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;
}
}
}
/// Dispatch to a fallible (`!void`-returning) hook on the
/// active tab. Errors are swallowed — matches the existing
/// `tab.activate(...) catch {}` idiom that this dispatcher
/// replaces. Use this for `activate`/`reload`/etc. where the
/// caller doesn't have a meaningful recovery path.
///
/// The contract validator already required `activate`/`reload`
/// to exist on every tab, so the `@hasDecl` guard is mostly
/// future-proofing for newly-added fallible hooks that aren't
/// universally required.
fn dispatchTry(self: *App, comptime hook_name: []const u8, args: anytype) void {
inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
if (std.mem.eql(u8, field.name, @tagName(self.active_tab))) {
const Module = @field(tab_modules, field.name);
if (!@hasDecl(Module.tab, hook_name)) return;
const fn_ptr = @field(Module.tab, hook_name);
const state_ptr = &@field(self.states, field.name);
@call(.auto, fn_ptr, .{ state_ptr, self } ++ args) catch {};
return;
}
}
}
/// Broadcast to every tab that declares `hook_name` — not just
/// the active tab. Tabs that don't declare it are skipped.
/// Order is `tab_modules` declaration order; callers should not
/// 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.meta.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.meta.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 &.{};
}
/// 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 (input_buffer.handleKey(&self.input_buf, &self.input_len, key)) {
.cancelled => {
self.mode = .normal;
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();
},
}
}
/// 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);
}
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 => {
tab_modules.portfolio.reloadPortfolioFile(&self.states.portfolio, self);
return ctx.consumeAndRedraw();
},
}
}
/// Returns true if this wheel event should be suppressed (too
/// close in time to the last one).
///
/// Terminals batch ~3-5 wheel events per physical detent.
/// Whether you want to absorb them depends on the semantics
/// of what wheel-up/down does in the current view:
///
/// - **Cursor-move semantics** ("one detent = one row"):
/// call this and bail on `true`. This is what cursor-bearing
/// tabs use via `moveBy` → `onCursorMove`, and what the
/// account-picker modal uses. Without debounce, one detent
/// jumps 5 rows.
///
/// - **Viewport-scroll semantics** ("one detent = N rows of
/// scroll"): do NOT debounce. Burst delivery at, say, 3 rows
/// per event × 5 events = 15 rows feels like fast scroll
/// rather than a glitch, and matches what users expect from
/// non-cursor views (quote chart, perf table, etc).
///
/// The state lives on App because terminals don't distinguish
/// "wheel events on the picker" from "wheel events on the
/// portfolio rows" — there's one ratcheting clock for the
/// whole app, even when the active surface changes between
/// events.
pub 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 {
// Each tab's `reload` hook owns its full per-tab refresh
// sequence: invalidate the svc cache, drop in-memory
// state, and re-fetch via `loadData` (or whatever the
// tab's loader is named). The framework dispatcher routes
// to the active tab's reload; tabs that share data
// (quote/performance) delegate via their reload bodies.
//
// Reload is contractually self-completing: when it
// returns, the tab's state is fully refreshed. There's no
// need for a follow-up `loadTabData` — `activate` would
// see `state.loaded = true` and no-op.
self.dispatchTry("reload", .{});
// Live-quote re-fetch: the quote tab's freshness display
// ("refreshed Xs ago") is driven by `states.quote.timestamp`,
// which is independent of the candles cache. The user's
// mental model for `r` includes "and the price ticker is
// current as of NOW," so we hit the live-quote endpoint
// here. Only on tabs that show a live quote.
//
// This is the one place where `r` reaches past the cache;
// tab-switches that activate quote/performance don't trigger
// it (they should keep using whatever cached price the user
// last fetched). When live-streaming quotes ship someday,
// this block goes away.
switch (self.active_tab) {
.quote, .performance => {
if (self.symbol.len > 0) {
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 => {},
}
}
/// Activate the current tab — load any data it needs, set
/// any per-tab UI state. Each tab's `activate` is responsible
/// for self-gating (e.g. skipping when `app.symbol.len == 0`
/// or when its data is already cached).
pub fn loadTabData(self: *App) void {
self.dispatchTry("activate", .{});
}
/// Free the cached portfolio summary on `app.portfolio`. Used
/// before re-fetching live prices (the summary is recomputed
/// from the new prices) and from `reload` to drop stale state.
/// `app.portfolio` is App-owned shared state — see
/// `PortfolioData` — so cleanup belongs here.
/// Ensure App-level portfolio data (`app.portfolio.summary`,
/// `.historical_snapshots`, `.watchlist_prices`,
/// `.latest_quote_date`) is populated. Idempotent — checks
/// `app.portfolio.loaded` and returns immediately if so.
///
/// Called by tabs that need portfolio data (portfolio,
/// analysis, history, projections). Each tab's `activate`
/// calls this; it doesn't touch any tab's UI state. The
/// portfolio tab's `activate` does its own UI setup
/// (sortAllocations, buildAccountList, rebuildRows) AFTER
/// this returns.
///
/// On first call, prefers `app.portfolio.prefetched_prices`
/// (populated before TUI startup); on subsequent calls
/// (after refresh has cleared `loaded`), fetches live via
/// `svc.loadPrices`.
///
/// On any error path, sets a status message and returns
/// early. Callers are not expected to inspect a result —
/// they read `app.portfolio.summary` after returning and
/// branch on `null`.
pub fn ensurePortfolioDataLoaded(self: *App) void {
if (self.portfolio.loaded) return;
self.portfolio.loaded = true;
self.freePortfolioSummary();
const pf = self.portfolio.file orelse return;
const positions = pf.positions(self.today, self.allocator) catch {
self.setStatus("Error computing positions");
return;
};
defer self.allocator.free(positions);
var prices = std.StringHashMap(f64).init(self.allocator);
defer prices.deinit();
// Only fetch prices for stock/ETF symbols (skip options, CDs, cash)
const syms = pf.stockSymbols(self.allocator) catch {
self.setStatus("Error getting symbols");
return;
};
defer self.allocator.free(syms);
var latest_date: ?zfin.Date = null;
var fail_count: usize = 0;
var fetch_count: usize = 0;
var stale_count: usize = 0;
var failed_syms: [8][]const u8 = undefined;
if (self.portfolio.prefetched_prices) |*pp| {
// Use pre-fetched prices from before TUI started (first load only)
for (syms) |sym| {
if (pp.get(sym)) |price| {
prices.put(sym, price) catch {};
}
}
// Extract watchlist prices
if (self.portfolio.watchlist_prices) |*wp| wp.clearRetainingCapacity() else {
self.portfolio.watchlist_prices = std.StringHashMap(f64).init(self.allocator);
}
var wp = &(self.portfolio.watchlist_prices.?);
var pp_iter = pp.iterator();
while (pp_iter.next()) |entry| {
if (!prices.contains(entry.key_ptr.*)) {
wp.put(entry.key_ptr.*, entry.value_ptr.*) catch {};
}
}
pp.deinit();
self.portfolio.prefetched_prices = null;
} else {
// Live fetch (refresh path) — fetch watchlist first, then stock prices
if (self.portfolio.watchlist_prices) |*wp| wp.clearRetainingCapacity() else {
self.portfolio.watchlist_prices = std.StringHashMap(f64).init(self.allocator);
}
var wp = &(self.portfolio.watchlist_prices.?);
if (self.watchlist) |wl| {
for (wl) |sym| {
const result = self.svc.getCandles(sym, .{}) catch continue;
defer result.deinit();
if (result.data.len > 0) {
wp.put(sym, result.data[result.data.len - 1].close) catch {};
}
}
}
for (pf.lots) |lot| {
if (lot.security_type == .watch) {
const sym = lot.priceSymbol();
const result = self.svc.getCandles(sym, .{}) catch continue;
defer result.deinit();
if (result.data.len > 0) {
wp.put(sym, result.data[result.data.len - 1].close) catch {};
}
}
}
// Fetch stock prices with TUI status-bar progress
const TuiProgress = struct {
app: *App,
failed: *[8][]const u8,
fail_n: usize = 0,
fn onProgress(ctx: *anyopaque, _: usize, _: usize, symbol: []const u8, status: zfin.DataService.SymbolStatus) void {
const s: *@This() = @ptrCast(@alignCast(ctx));
switch (status) {
.fetching => {
var buf: [64]u8 = undefined;
const msg = std.fmt.bufPrint(&buf, "Loading {s}...", .{symbol}) catch "Loading...";
s.app.setStatus(msg);
},
.failed, .failed_used_stale => {
if (s.fail_n < s.failed.len) {
s.failed[s.fail_n] = symbol;
s.fail_n += 1;
}
},
else => {},
}
}
fn callback(s: *@This()) zfin.DataService.ProgressCallback {
return .{
.context = @ptrCast(s),
.on_progress = onProgress,
};
}
};
var tui_progress = TuiProgress{ .app = self, .failed = &failed_syms };
const load_result = self.svc.loadPrices(syms, &prices, false, tui_progress.callback());
latest_date = load_result.latest_date;
fail_count = load_result.fail_count;
fetch_count = load_result.fetched_count;
stale_count = load_result.stale_count;
}
self.portfolio.latest_quote_date = latest_date;
// Build portfolio summary, candle map, and historical snapshots
var pf_data = cli.buildPortfolioData(self.allocator, pf, positions, syms, &prices, self.svc, self.today) catch |err| switch (err) {
error.NoAllocations => {
self.setStatus("No cached prices. Run: zfin perf <SYMBOL> first");
return;
},
error.SummaryFailed => {
self.setStatus("Error computing portfolio summary");
return;
},
else => {
self.setStatus("Error building portfolio data");
return;
},
};
// Transfer ownership: summary stored on App, candle_map freed after snapshots extracted
self.portfolio.summary = pf_data.summary;
self.portfolio.historical_snapshots = pf_data.snapshots;
{
var it = pf_data.candle_map.valueIterator();
while (it.next()) |v| self.allocator.free(v.*);
pf_data.candle_map.deinit();
}
// Show warning if any securities failed to load
if (fail_count > 0) {
var warn_buf: [256]u8 = undefined;
if (fail_count <= 3) {
// Show actual symbol names for easier debugging
var sym_buf: [128]u8 = undefined;
var sym_len: usize = 0;
const show = @min(fail_count, failed_syms.len);
for (0..show) |fi| {
if (sym_len > 0) {
if (sym_len + 2 < sym_buf.len) {
sym_buf[sym_len] = ',';
sym_buf[sym_len + 1] = ' ';
sym_len += 2;
}
}
const s = failed_syms[fi];
const copy_len = @min(s.len, sym_buf.len - sym_len);
@memcpy(sym_buf[sym_len..][0..copy_len], s[0..copy_len]);
sym_len += copy_len;
}
if (stale_count > 0) {
const warn_msg = std.fmt.bufPrint(&warn_buf, "Failed to refresh: {s} (using stale cache)", .{sym_buf[0..sym_len]}) catch "Warning: some securities failed";
self.setStatus(warn_msg);
} else {
const warn_msg = std.fmt.bufPrint(&warn_buf, "Failed to load: {s}", .{sym_buf[0..sym_len]}) catch "Warning: some securities failed";
self.setStatus(warn_msg);
}
} else {
if (stale_count > 0 and stale_count == fail_count) {
const warn_msg = std.fmt.bufPrint(&warn_buf, "{d} symbols failed to refresh (using stale cache) | r/F5 to retry", .{fail_count}) catch "Warning: some securities used stale cache";
self.setStatus(warn_msg);
} else {
const warn_msg = std.fmt.bufPrint(&warn_buf, "Warning: {d} securities failed to load prices", .{fail_count}) catch "Warning: some securities failed";
self.setStatus(warn_msg);
}
}
} else if (fetch_count > 0) {
var info_buf: [128]u8 = undefined;
const info_msg = std.fmt.bufPrint(&info_buf, "Loaded {d} symbols ({d} fetched) | r/F5 to refresh", .{ syms.len, fetch_count }) catch "Loaded | r/F5 to refresh";
self.setStatus(info_msg);
} else {
// Empty status — App's getStatus() will fall back to the
// dynamic default hint composed from the active tab's
// status_hints + global keys.
self.setStatus("");
}
}
pub fn freePortfolioSummary(self: *App) void {
if (self.portfolio.summary) |*s| s.deinit(self.allocator);
self.portfolio.summary = null;
}
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;
}
/// Cell pixel size for the active terminal, used by tabs that
/// render bitmap charts via the Kitty graphics protocol. Falls
/// back to (8, 16) when vaxis hasn't reported pixel dimensions
/// yet (terminal didn't answer the size query, or we're early
/// in startup before the first frame).
///
/// Returns the dimensions vaxis itself would put in
/// `DrawContext.cell_size`, so tabs don't have to thread `ctx`
/// through their `drawContent` hook just to size an image.
pub fn cellPixelSize(self: *const App) struct { width: u32, height: u32 } {
const va = self.vx_app orelse return .{ .width = 8, .height = 16 };
const screen = &va.vx.screen;
if (screen.width == 0 or screen.height == 0) return .{ .width = 8, .height = 16 };
const w = screen.width_pix / screen.width;
const h = screen.height_pix / screen.height;
return .{
.width = if (w > 0) @as(u32, w) else 8,
.height = if (h > 0) @as(u32, h) else 16,
};
}
/// 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.meta.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.meta.action_labels.get(hint_action);
if (label.len == 0) continue;
try fragments.append(arena, .{ .key = keys[0], .label = label });
}
}
}
return formatStatusHint(arena, fragments.items);
}
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);
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 {
try self.dispatchDraw(ctx.arena, buf, width, height);
}
return .{ .size = .{ .width = width, .height = height }, .widget = self.widget(), .buffer = buf, .children = &.{} };
}
/// Dispatch the active tab's draw hook. Each tab declares
/// EXACTLY ONE of `buildStyledLines` (line-list rendering;
/// App handles scroll clamping + cell rendering) or
/// `drawContent` (direct buffer; for layouts that don't fit
/// the line-list shape, e.g. Kitty-graphics chart frames).
/// The framework validator (in `tab_framework.zig`) enforces
/// the exactly-one rule at compile time, so the
/// `@hasDecl` branches below are total.
fn dispatchDraw(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !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);
const state_ptr = &@field(self.states, field.name);
if (@hasDecl(Module, "drawContent")) {
return Module.drawContent(state_ptr, self, arena, buf, width, height);
}
// buildStyledLines — by the validator's exactly-one
// rule, this branch must be reached when drawContent
// isn't declared.
const lines = try Module.buildStyledLines(state_ptr, self, arena);
const start = @min(self.scroll_offset, if (lines.len > 0) lines.len - 1 else 0);
return self.drawStyledContent(arena, buf, width, height, lines[start..]);
}
}
}
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);
// Truly global modes: symbol input is App-owned.
if (self.mode == .symbol_input) {
self.renderInputPrompt(buf, width, "Symbol: ", " Enter=confirm Esc=cancel ");
return .{ .size = .{ .width = width, .height = 1 }, .widget = self.widget(), .buffer = buf, .children = &.{} };
}
// Tab-internal modal? Active tab declares `statusOverride`
// and is in a modal sub-state.
if (self.activeTabStatusOverride()) |override| {
switch (override) {
.hint => |text| {
const prompt_style = t.inputStyle();
@memset(buf, .{ .char = .{ .grapheme = " " }, .style = prompt_style });
for (0..@min(text.len, width)) |i| {
buf[i] = .{ .char = .{ .grapheme = glyph(text[i]) }, .style = prompt_style };
}
},
.input_prompt => |ip| self.renderInputPrompt(buf, width, ip.prompt, ip.hint),
}
return .{ .size = .{ .width = width, .height = 1 }, .widget = self.widget(), .buffer = buf, .children = &.{} };
}
// Default status bar: getStatus() + optional account-filter
// suffix on the portfolio tab.
const status_style = t.statusStyle();
@memset(buf, .{ .char = .{ .grapheme = " " }, .style = status_style });
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 = &.{} };
}
/// Call the active tab's `statusOverride` hook (when declared)
/// and return its result. Comptime-walks `tab_modules` to find
/// the matching scope. Used by `drawStatusBar` to let tabs
/// take over the status row during tab-internal modals.
fn activeTabStatusOverride(self: *App) ?tab_framework.StatusOverride {
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, "statusOverride")) return null;
const state_ptr = &@field(self.states, field.name);
return Module.tab.statusOverride(state_ptr, self);
}
}
return null;
}
// ── 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.meta.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;
/// 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.meta.default_bindings.len == 0) continue;
const heading = "Tab: " ++ field.name;
try keybinds.printSectionHeader(out, heading);
for (Module.meta.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")) {
// -s / --symbol require a non-flag value. Bare `-s` and
// `-s --chart …` are both user errors — surface them
// explicitly rather than silently dropping the flag.
const flag = args[i];
if (i + 1 >= args.len) {
try cli.stderrPrint(io, "Error: ");
try cli.stderrPrint(io, flag);
try cli.stderrPrint(io, " requires a symbol value\n");
return error.InvalidArgs;
}
i += 1;
const value = args[i];
if (value.len > 0 and value[0] == '-') {
try cli.stderrPrint(io, "Error: ");
try cli.stderrPrint(io, flag);
try cli.stderrPrint(io, " requires a symbol value, got flag: ");
try cli.stderrPrint(io, value);
try cli.stderrPrint(io, "\n");
return error.InvalidArgs;
}
const len = @min(value.len, symbol_upper_buf.len);
_ = std.ascii.upperString(symbol_upper_buf[0..len], value[0..len]);
symbol = symbol_upper_buf[0..len];
has_explicit_symbol = true;
skip_watchlist = true;
} else if (std.mem.eql(u8, args[i], "--chart")) {
// Same shape as -s / --symbol: require a value, reject
// flag-shaped values.
if (i + 1 >= args.len) {
try cli.stderrPrint(io, "Error: --chart requires a value (e.g. 80x24)\n");
return error.InvalidArgs;
}
i += 1;
const value = args[i];
if (value.len > 0 and value[0] == '-') {
try cli.stderrPrint(io, "Error: --chart requires a value, got flag: ");
try cli.stderrPrint(io, value);
try cli.stderrPrint(io, "\n");
return error.InvalidArgs;
}
if (chart.ChartConfig.parse(value)) |cc| {
chart_config = cc;
} else {
try cli.stderrPrint(io, "Error: --chart value is not a valid WIDTHxHEIGHT spec: ");
try cli.stderrPrint(io, value);
try cli.stderrPrint(io, "\n");
return error.InvalidArgs;
}
} else if (args[i].len > 0 and args[i][0] == '-') {
// Any flag we didn't recognize. Reject explicitly rather
// than silently passing through to the positional-symbol
// branch (which would then ignore it).
try cli.stderrPrint(io, "Error: unknown flag: ");
try cli.stderrPrint(io, args[i]);
try cli.stderrPrint(io, "\nRun 'zfin interactive --help' for usage.\n");
return error.InvalidArgs;
} else if (args[i].len > 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,
.auto, // refresh policy: TUI is interactive; honor TTLs
true, // color
);
app_inst.portfolio.prefetched_prices = load_result.prices;
}
// Pre-load PortfolioData while the terminal is still in
// normal mode — `loadPrices` emits stderr progress that
// would be invisible after vaxis takes over the screen.
// Each tab that needs the data also calls
// `ensurePortfolioDataLoaded` from its `activate`
// (idempotent), so this is a UX optimization, not a
// correctness requirement.
if (app_inst.portfolio.file != null) {
app_inst.ensurePortfolioDataLoaded();
}
}
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 "Tab label" {
try testing.expectEqualStrings(" 1:Portfolio ", tabLabel(.portfolio));
try testing.expectEqualStrings(" 2: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.meta.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.meta.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.meta.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;
}
}