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