zfin/src/tui.zig

2738 lines
118 KiB
Zig

const std = @import("std");
const vaxis = @import("vaxis");
const zfin = @import("root.zig");
const fmt = @import("format.zig");
const views = @import("views/portfolio_sections.zig");
const cli = @import("commands/common.zig");
const keybinds = @import("tui/keybinds.zig");
const theme = @import("tui/theme.zig");
const chart = @import("tui/chart.zig");
const portfolio_tab = @import("tui/portfolio_tab.zig");
const quote_tab = @import("tui/quote_tab.zig");
const perf_tab = @import("tui/perf_tab.zig");
const options_tab = @import("tui/options_tab.zig");
const earnings_tab = @import("tui/earnings_tab.zig");
const analysis_tab = @import("tui/analysis_tab.zig");
const history_tab = @import("tui/history_tab.zig");
const projections_tab = @import("tui/projections_tab.zig");
const history = @import("history.zig");
const timeline = @import("analytics/timeline.zig");
const compare_core = @import("compare.zig");
const compare_view = @import("views/compare.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 " ";
}
pub const Tab = enum {
portfolio,
quote,
performance,
options,
earnings,
analysis,
history,
projections,
fn label(self: Tab) []const u8 {
return switch (self) {
.portfolio => " 1:Portfolio ",
.quote => " 2:Quote ",
.performance => " 3:Performance ",
.options => " 4:Options ",
.earnings => " 5:Earnings ",
.analysis => " 6:Analysis ",
.history => " 7:History ",
.projections => " 8:Projections ",
};
}
};
const tabs = [_]Tab{ .portfolio, .quote, .performance, .options, .earnings, .analysis, .history, .projections };
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,
};
/// Backing resources for the history tab's active compare view.
///
/// Both endpoints are tracked independently. Snapshot endpoints own
/// their `SnapshotSide` (which includes the snapshot bytes the
/// HoldingMap keys borrow from). Live endpoints own only a
/// `HoldingMap`; the map's keys borrow from `App.portfolio`, which
/// outlives this struct.
///
/// Deinit order is important: the `CompareView` must be deinit'd
/// before these resources, because the view's `symbols` slice contains
/// `SymbolChange.symbol` strings that borrow from one of the maps
/// (the "then" side, per `buildCompareView`).
pub const HistoryCompareResources = struct {
then_snap: ?compare_core.SnapshotSide = null,
now_snap: ?compare_core.SnapshotSide = null,
then_live_map: ?compare_view.HoldingMap = null,
now_live_map: ?compare_view.HoldingMap = null,
pub fn deinit(self: *HistoryCompareResources, allocator: std.mem.Allocator) void {
if (self.then_snap) |*s| s.deinit(allocator);
if (self.now_snap) |*s| s.deinit(allocator);
if (self.then_live_map) |*m| m.deinit();
if (self.now_live_map) |*m| m.deinit();
}
};
// ── 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 {
config: chart.ChartConfig = .{},
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;
}
};
/// 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,
/// 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: ?zfin.Portfolio = null,
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 navigation
cursor: usize = 0, // selected row in portfolio view
expanded: [64]bool = @splat(false), // which positions are expanded
cash_expanded: bool = false, // whether cash section is expanded to show per-account
illiquid_expanded: bool = false, // whether illiquid section is expanded to show per-asset
portfolio_rows: std.ArrayList(PortfolioRow) = .empty,
prepared_options: ?views.Options = null,
prepared_cds: ?views.CDs = null,
portfolio_header_lines: usize = 0, // number of styled lines before data rows
portfolio_line_to_row: [256]usize = @splat(0), // maps styled line index -> portfolio_rows index
portfolio_line_count: usize = 0, // total styled lines in portfolio view
portfolio_sort_field: PortfolioSortField = .symbol, // current sort column
portfolio_sort_dir: SortDirection = .asc, // current sort direction
watchlist_prices: ?std.StringHashMap(f64) = null, // cached watchlist prices (no disk I/O during render)
prefetched_prices: ?std.StringHashMap(f64) = null, // prices loaded before TUI starts (with stderr progress)
// Account filter state
account_filter: ?[]const u8 = null, // active account filter (owned copy; null = all accounts)
filtered_positions: ?[]zfin.Position = null, // positions for filtered account (from positionsForAccount)
account_list: std.ArrayList([]const u8) = .empty, // distinct accounts from portfolio lots (ordered by accounts.srf)
account_numbers: std.ArrayList(?[]const u8) = .empty, // account_number from accounts.srf (parallel to account_list)
account_shortcut_keys: std.ArrayList(u8) = .empty, // auto-assigned shortcut key per account (parallel to account_list)
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 account_list matching search
account_search_cursor: usize = 0, // cursor within search_matches
// Options navigation (inline expand/collapse like portfolio)
options_cursor: usize = 0, // selected row in flattened options view
options_expanded: [64]bool = @splat(false), // which expirations are expanded
options_calls_collapsed: [64]bool = @splat(false), // per-expiration: calls section collapsed
options_puts_collapsed: [64]bool = @splat(false), // per-expiration: puts section collapsed
options_near_the_money: usize = 8, // +/- strikes from ATM
options_rows: std.ArrayList(OptionsRow) = .empty,
options_header_lines: usize = 0, // number of styled lines before data rows
// Cached data for rendering
candles: ?[]zfin.Candle = null,
dividends: ?[]zfin.Dividend = null,
earnings_data: ?[]zfin.EarningsEvent = null,
options_data: ?[]zfin.OptionsChain = null,
portfolio_summary: ?zfin.valuation.PortfolioSummary = null,
historical_snapshots: ?[zfin.valuation.HistoricalPeriod.all.len]zfin.valuation.HistoricalSnapshot = null,
risk_metrics: ?zfin.risk.TrailingRisk = null,
trailing_price: ?zfin.performance.TrailingReturns = null,
trailing_total: ?zfin.performance.TrailingReturns = null,
trailing_me_price: ?zfin.performance.TrailingReturns = null,
trailing_me_total: ?zfin.performance.TrailingReturns = null,
candle_count: usize = 0,
candle_first_date: ?zfin.Date = null,
candle_last_date: ?zfin.Date = null,
data_error: ?[]const u8 = null,
perf_loaded: bool = false,
earnings_loaded: bool = false,
options_loaded: bool = false,
portfolio_loaded: bool = false,
// Data timestamps (unix seconds)
candle_timestamp: i64 = 0,
options_timestamp: i64 = 0,
earnings_timestamp: i64 = 0,
// Stored real-time quote (only fetched on manual refresh)
quote: ?zfin.Quote = null,
quote_timestamp: i64 = 0,
// Track whether earnings tab should be disabled (ETF, no data)
earnings_disabled: bool = false,
earnings_error: ?[]const u8 = null, // error message to show in content area
// ETF profile (loaded lazily on quote tab)
etf_profile: ?zfin.EtfProfile = null,
etf_loaded: bool = false,
// Analysis tab state
analysis_result: ?zfin.analysis.AnalysisResult = null,
analysis_loaded: bool = false,
analysis_disabled: bool = false, // true when no portfolio loaded (analysis requires portfolio)
classification_map: ?zfin.classification.ClassificationMap = null,
account_map: ?zfin.analysis.AccountMap = null,
// History tab state
history_loaded: bool = false,
history_disabled: bool = false, // true when no portfolio path (history requires it)
history_timeline: ?history.LoadedTimeline = null,
// Cursor for the recent-snapshots table. 0 = newest row (live
// pseudo-row if available, otherwise newest snapshot).
history_cursor: usize = 0,
// Up to two rows marked for comparison via `compare_select`
// (default 's' / space). Entries are indices into the displayed
// table. `null` slots mean "no selection". Fixed-size array pins
// the cap at type level.
history_selections: [2]?usize = .{ null, null },
// Active compare view. When non-null, the history tab renders
// compare output instead of the timeline. Cleared by
// `compare_cancel` (default Esc) or toggling compare_commit again.
history_compare_view: ?compare_view.CompareView = null,
// Resources backing `history_compare_view` — owned by the App so
// their lifetime matches the view's. Cleared together with the
// view.
history_compare_resources: ?HistoryCompareResources = null,
// First line-number where the recent-snapshots table body starts.
// Set during `history_tab.buildStyledLines`; consumed by the key
// handler's ensure-cursor-visible logic.
history_table_first_line: usize = 0,
// Number of rows currently rendered in the table (including the
// live pseudo-row when present). Used for cursor clamping.
history_table_row_count: usize = 0,
// Projections tab state
projections_loaded: bool = false,
projections_disabled: bool = false,
projections_config: @import("analytics/projections.zig").UserConfig = .{},
projections_ctx: ?@import("views/projections.zig").ProjectionContext = null,
projections_horizon_idx: usize = 0,
projections_image_id: ?u32 = null, // Kitty graphics image ID for projection chart
projections_image_width: u16 = 0,
projections_image_height: u16 = 0,
projections_chart_dirty: bool = true,
projections_chart_visible: bool = true,
projections_events_enabled: bool = true,
projections_value_min: f64 = 0,
projections_value_max: f64 = 0,
/// When non-null, the projections tab renders against a historical
/// snapshot instead of the live portfolio. Set via the `d` popup
/// (parsed by `cli.parseAsOfDate`) and auto-snapped to the nearest
/// earlier available snapshot. Cleared by `D` or by committing
/// an empty / "live" input.
projections_as_of: ?zfin.Date = null,
/// When auto-snap kicked in, `projections_as_of` is the resolved
/// snapshot date but `projections_as_of_requested` remembers what
/// the user actually typed — surfaced in the tab header as a muted
/// "(requested X; snapped to Y, N days earlier)" note.
projections_as_of_requested: ?zfin.Date = null,
/// When true, the projections chart overlays the realized
/// portfolio trajectory (snapshots + imported_values) on top of
/// the percentile bands. Toggled by the `o` keybind. Only
/// meaningful when `projections_as_of` is set; the keybind
/// flashes a status message and leaves this off otherwise.
projections_overlay_actuals: bool = false,
// Default to `.liquid` — that's the metric most worth watching
// day-to-day. Illiquid barely changes, net_worth is dominated by
// liquid anyway, so "show me liquid" is the headline view.
history_metric: timeline.Metric = .liquid,
/// Forced resolution for the history table + chart. Null means
/// "default" — interpreted as cascading by the renderer. Cycled
/// via the `history_resolution_next` keybind ('t' by default).
history_resolution: ?timeline.Resolution = null,
/// Buckets that the user has explicitly expanded in the
/// cascading-view recent-snapshots table. Keyed by
/// `(tier, bucket_start.days)` so that a parent and its
/// edge-aligned child (e.g. yearly 2024 starts on the same
/// day as quarterly Q1 2024) are distinct.
/// Default: empty — every bucket is collapsed at the
/// drilldown level. Daily rows for the last 14 days are
/// always shown inline.
history_expanded_buckets: std.AutoHashMap(history_tab.BucketKey, void) = undefined,
// Mouse wheel debounce for cursor-based tabs (portfolio, options).
// Terminals often send multiple wheel events per physical tick.
last_wheel_ns: i128 = 0,
// Chart state (Kitty graphics)
chart: ChartState = .{},
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.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 >= portfolio_tab.account_picker_header_lines) {
const item_idx = content_row - portfolio_tab.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(t.label().len);
if (mouse.col >= col and mouse.col < col + lbl_len) {
if (self.isTabDisabled(t)) return;
self.active_tab = t;
self.scroll_offset = 0;
self.loadTabData();
ctx.queueRefresh() catch {};
return ctx.consumeAndRedraw();
}
col += lbl_len;
}
}
// Portfolio tab: click header to sort, click row to expand/collapse
if (self.active_tab == .portfolio and mouse.row > 0) {
const content_row = @as(usize, @intCast(mouse.row)) + self.scroll_offset;
// Click on column header row -> sort by that column
if (self.portfolio_header_lines > 0 and content_row == self.portfolio_header_lines - 1) {
const col = @as(usize, @intCast(mouse.col));
const new_field: ?PortfolioSortField =
if (col < portfolio_tab.col_end_symbol)
.symbol
else if (col < portfolio_tab.col_end_shares)
.shares
else if (col < portfolio_tab.col_end_avg_cost)
.avg_cost
else if (col < portfolio_tab.col_end_price)
.price
else if (col < portfolio_tab.col_end_market_value)
.market_value
else if (col < portfolio_tab.col_end_gain_loss)
.gain_loss
else if (col < portfolio_tab.col_end_weight)
.weight
else if (col < portfolio_tab.col_end_date)
null // Date (not sortable)
else
.account;
if (new_field) |nf| {
if (nf == self.portfolio_sort_field) {
self.portfolio_sort_dir = self.portfolio_sort_dir.flip();
} else {
self.portfolio_sort_field = nf;
self.portfolio_sort_dir = if (nf == .symbol or nf == .account) .asc else .desc;
}
self.sortPortfolioAllocations();
self.rebuildPortfolioRows();
return ctx.consumeAndRedraw();
}
}
if (content_row >= self.portfolio_header_lines and self.portfolio_rows.items.len > 0) {
const line_idx = content_row - self.portfolio_header_lines;
if (line_idx < self.portfolio_line_count and line_idx < self.portfolio_line_to_row.len) {
const row_idx = self.portfolio_line_to_row[line_idx];
if (row_idx < self.portfolio_rows.items.len) {
self.cursor = row_idx;
self.toggleExpand();
return ctx.consumeAndRedraw();
}
}
}
}
// Quote tab: click on timeframe selector to switch timeframes
if (self.active_tab == .quote and mouse.row > 0) {
if (self.chart.timeframe_row) |tf_row| {
const content_row = @as(usize, @intCast(mouse.row)) + self.scroll_offset;
if (content_row == tf_row) {
// " Chart: [6M] YTD 1Y 3Y 5Y ([ ] to change)"
// Prefix " Chart: " = 9 chars, then each TF takes label_len+2 (brackets/spaces) + 1 gap
const col = @as(usize, @intCast(mouse.col));
const prefix_len: usize = 9; // " Chart: "
if (col >= prefix_len) {
const timeframes = [_]chart.Timeframe{ .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" };
var x: usize = prefix_len;
for (timeframes) |tf| {
const lbl_len = tf.label().len;
const slot_width = lbl_len + 2 + 1; // [XX] + space or XX + space
if (col >= x and col < x + slot_width) {
if (tf != self.chart.timeframe) {
self.chart.timeframe = tf;
self.setStatus(tf.label());
return ctx.consumeAndRedraw();
}
break;
}
x += slot_width;
}
}
}
}
}
// Options tab: single-click to select and expand/collapse
if (self.active_tab == .options and mouse.row > 0) {
const content_row = @as(usize, @intCast(mouse.row)) + self.scroll_offset;
if (content_row >= self.options_header_lines and self.options_rows.items.len > 0) {
// Walk options_rows tracking styled line position to find which
// row was clicked. Each row = 1 styled line, except puts_header
// which emits an extra blank line before it.
const target_line = content_row - self.options_header_lines;
var current_line: usize = 0;
for (self.options_rows.items, 0..) |orow, oi| {
if (orow.kind == .puts_header) current_line += 1; // extra blank
if (current_line == target_line) {
self.options_cursor = oi;
self.toggleOptionsExpand();
return ctx.consumeAndRedraw();
}
current_line += 1;
}
}
}
// History tab: click a tier header to expand/collapse;
// click a bucket/snapshot row to move the cursor.
if (self.active_tab == .history and self.history_compare_view == null and mouse.row > 0) {
const content_row = @as(usize, @intCast(mouse.row)) + self.scroll_offset;
if (content_row >= self.history_table_first_line and self.history_table_row_count > 0) {
const row_idx = content_row - self.history_table_first_line;
if (row_idx < self.history_table_row_count) {
// Move the cursor to the clicked row, then
// try to toggle if it's a tier header. Both
// outcomes consume the click and redraw.
self.history_cursor = row_idx;
_ = history_tab.toggleTierAtCursor(self);
return ctx.consumeAndRedraw();
}
}
}
},
else => {},
}
}
/// 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.projections_as_of = d;
self.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.projections_as_of = null;
self.projections_as_of_requested = null;
self.setStatus("As-of cleared — showing live");
}
projections_tab.freeLoaded(self);
self.projections_loaded = false;
projections_tab.loadData(self);
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.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.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.account_list.items, 0..) |acct, i| {
if (containsLower(acct, lq)) {
self.account_search_matches.append(self.allocator, i) catch continue;
} else if (i < self.account_numbers.items.len) {
if (self.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.account_list.items.len) {
self.setAccountFilter(self.account_list.items[idx]);
}
}
self.mode = .normal;
self.cursor = 0;
self.scroll_offset = 0;
portfolio_tab.rebuildPortfolioRows(self);
if (self.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.account_map != null) return;
const ppath = self.portfolio_path orelse return;
self.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.account_filter) |old| self.allocator.free(old);
if (self.filtered_positions) |fp| self.allocator.free(fp);
self.filtered_positions = null;
if (name) |n| {
self.account_filter = self.allocator.dupe(u8, n) catch null;
if (self.portfolio) |pf| {
self.filtered_positions = pf.positionsForAccount(self.today, self.allocator, n) catch null;
}
} else {
self.account_filter = null;
}
}
fn handleNormalKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void {
// Ctrl+L: full screen redraw (standard TUI convention, not configurable)
if (key.codepoint == 'l' and key.mods.ctrl) {
ctx.queueRefresh() catch {};
return ctx.consumeAndRedraw();
}
// History-tab compare intercept.
//
// `s` / space / `c` / escape have existing global bindings
// (select_symbol, collapse_all_calls, plus the account-filter
// escape handler below) that would otherwise handle (or
// silently consume) these keys. This intercept runs first when
// the user is in the history tab so compare behavior wins.
if (self.active_tab == .history) {
if (history_tab.handleCompareKey(self, ctx, key)) return;
}
// Escape: clear account filter on portfolio tab, clear as-of
// on projections tab, no-op otherwise.
if (key.codepoint == vaxis.Key.escape) {
if (self.active_tab == .portfolio and self.account_filter != null) {
self.setAccountFilter(null);
self.cursor = 0;
self.scroll_offset = 0;
portfolio_tab.rebuildPortfolioRows(self);
self.setStatus("Filter cleared: showing all accounts");
return ctx.consumeAndRedraw();
}
if (self.active_tab == .projections and self.projections_as_of != null) {
self.projections_as_of = null;
self.projections_as_of_requested = null;
self.projections_overlay_actuals = false;
projections_tab.freeLoaded(self);
self.projections_loaded = false;
projections_tab.loadData(self);
self.setStatus("As-of cleared — showing live");
return ctx.consumeAndRedraw();
}
return;
}
const action = self.keymap.matchAction(key) orelse return;
switch (action) {
.quit => {
ctx.quit = true;
},
.symbol_input => {
self.mode = .symbol_input;
self.input_len = 0;
return ctx.consumeAndRedraw();
},
.select_symbol => {
// 's' selects the current portfolio row's symbol as the active symbol
if (self.active_tab == .portfolio and self.portfolio_rows.items.len > 0 and self.cursor < self.portfolio_rows.items.len) {
const row = self.portfolio_rows.items[self.cursor];
self.setActiveSymbol(row.symbol);
// Format into a separate buffer to avoid aliasing with status_msg
var tmp_buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&tmp_buf, "Active: {s}", .{row.symbol}) catch "Active";
self.setStatus(msg);
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.isTabDisabled(target)) return;
self.active_tab = target;
self.scroll_offset = 0;
self.loadTabData();
ctx.queueRefresh() catch {};
return ctx.consumeAndRedraw();
}
},
.select_next => {
self.moveBy(1);
return ctx.consumeAndRedraw();
},
.select_prev => {
self.moveBy(-1);
return ctx.consumeAndRedraw();
},
.expand_collapse => {
if (self.active_tab == .portfolio) {
self.toggleExpand();
return ctx.consumeAndRedraw();
} else if (self.active_tab == .options) {
self.toggleOptionsExpand();
return ctx.consumeAndRedraw();
} else if (self.active_tab == .history) {
if (history_tab.toggleTierAtCursor(self)) {
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;
if (self.active_tab == .portfolio) self.cursor = 0;
if (self.active_tab == .options) self.options_cursor = 0;
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
if (self.active_tab == .portfolio and self.portfolio_rows.items.len > 0)
self.cursor = self.portfolio_rows.items.len - 1;
if (self.active_tab == .options and self.options_rows.items.len > 0)
self.options_cursor = self.options_rows.items.len - 1;
return ctx.consumeAndRedraw();
},
.help => {
self.mode = .help;
self.scroll_offset = 0;
return ctx.consumeAndRedraw();
},
.reload_portfolio => {
self.reloadPortfolioFile();
return ctx.consumeAndRedraw();
},
.collapse_all_calls => {
if (self.active_tab == .options) {
self.toggleAllCallsPuts(true);
return ctx.consumeAndRedraw();
}
},
.collapse_all_puts => {
if (self.active_tab == .options) {
self.toggleAllCallsPuts(false);
return ctx.consumeAndRedraw();
}
},
.options_filter_1, .options_filter_2, .options_filter_3, .options_filter_4, .options_filter_5, .options_filter_6, .options_filter_7, .options_filter_8, .options_filter_9 => {
if (self.active_tab == .options) {
const n = @intFromEnum(action) - @intFromEnum(keybinds.Action.options_filter_1) + 1;
self.options_near_the_money = n;
self.rebuildOptionsRows();
var tmp_buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&tmp_buf, "Filtered to +/- {d} strikes NTM", .{n}) catch "Filtered";
self.setStatus(msg);
return ctx.consumeAndRedraw();
}
},
.chart_timeframe_next => {
if (self.active_tab == .quote) {
self.chart.timeframe = self.chart.timeframe.next();
self.chart.dirty = true;
self.setStatus(self.chart.timeframe.label());
return ctx.consumeAndRedraw();
}
},
.chart_timeframe_prev => {
if (self.active_tab == .quote) {
self.chart.timeframe = self.chart.timeframe.prev();
self.chart.dirty = true;
self.setStatus(self.chart.timeframe.label());
return ctx.consumeAndRedraw();
}
},
.history_metric_next => {
if (self.active_tab == .history) {
history_tab.cycleMetric(self);
return ctx.consumeAndRedraw();
}
},
.history_resolution_next => {
if (self.active_tab == .history) {
history_tab.cycleResolution(self);
return ctx.consumeAndRedraw();
}
},
.sort_col_next => {
if (self.active_tab == .portfolio) {
if (self.portfolio_sort_field.next()) |new_field| {
self.portfolio_sort_field = new_field;
self.portfolio_sort_dir = if (new_field == .symbol or new_field == .account) .asc else .desc;
self.sortPortfolioAllocations();
self.rebuildPortfolioRows();
}
return ctx.consumeAndRedraw();
}
},
.sort_col_prev => {
if (self.active_tab == .portfolio) {
if (self.portfolio_sort_field.prev()) |new_field| {
self.portfolio_sort_field = new_field;
self.portfolio_sort_dir = if (new_field == .symbol or new_field == .account) .asc else .desc;
self.sortPortfolioAllocations();
self.rebuildPortfolioRows();
}
return ctx.consumeAndRedraw();
}
},
.sort_reverse => {
// The `o` keybind dual-dispatches by active tab:
// - portfolio → flip the sort direction
// - projections → toggle the actuals-overlay
// `matchAction` is first-match-wins so we can't have
// separate Action variants share a codepoint; routing
// via the active tab in the handler is the project's
// existing pattern (see also `s` → compare_select /
// select_symbol). The action name stays `sort_reverse`
// because portfolio was the first consumer.
if (self.active_tab == .portfolio) {
self.portfolio_sort_dir = self.portfolio_sort_dir.flip();
self.sortPortfolioAllocations();
self.rebuildPortfolioRows();
return ctx.consumeAndRedraw();
}
if (self.active_tab == .projections) {
if (self.projections_as_of == null) {
self.setStatus("Overlay only available with --as-of (press d to set)");
return ctx.consumeAndRedraw();
}
self.projections_overlay_actuals = !self.projections_overlay_actuals;
// Re-run loadData so the overlay section gets
// built (or freed). The timeline load is the
// expensive bit but it's rare — humans toggle
// this maybe a few times per session.
projections_tab.freeLoaded(self);
self.projections_loaded = false;
projections_tab.loadData(self);
self.projections_chart_dirty = true;
if (self.projections_overlay_actuals) {
self.setStatus("Overlay: ON — tracks trajectory, not SWR validity");
} else {
self.setStatus("Overlay: OFF");
}
return ctx.consumeAndRedraw();
}
},
.account_filter => {
if (self.active_tab == .portfolio and self.portfolio != null) {
self.mode = .account_picker;
// Position cursor on the currently-active filter (or 0 for "All")
self.account_picker_cursor = 0;
if (self.account_filter) |af| {
for (self.account_list.items, 0..) |acct, ai| {
if (std.mem.eql(u8, acct, af)) {
self.account_picker_cursor = ai + 1; // +1 because 0 = "All accounts"
break;
}
}
}
return ctx.consumeAndRedraw();
}
},
.toggle_chart => {
if (self.active_tab == .projections) {
self.projections_chart_visible = !self.projections_chart_visible;
self.projections_chart_dirty = true;
self.scroll_offset = 0;
return ctx.consumeAndRedraw();
}
},
.toggle_events => {
if (self.active_tab == .projections) {
self.projections_events_enabled = !self.projections_events_enabled;
projections_tab.freeLoaded(self);
self.projections_loaded = false;
projections_tab.loadData(self);
const label = if (self.projections_events_enabled) "Events enabled" else "Events disabled";
self.setStatus(label);
return ctx.consumeAndRedraw();
}
},
.projections_as_of_input => {
// Only meaningful on the projections tab. Other tabs
// let the same key flow to their own handlers (none
// currently bind plain 'd').
if (self.active_tab == .projections) {
self.mode = .date_input;
self.input_len = 0;
// No setStatus — drawStatusBar replaces the whole
// line with the prompt + hint when mode is .date_input.
return ctx.consumeAndRedraw();
}
},
// History-tab compare actions are normally intercepted in
// `handleCompareKey` before `matchAction` runs (because the
// default 's'/'c'/space/escape key bindings belong to other
// actions). These cases exist so the switch is exhaustive
// and so future user-supplied keybindings targeting these
// action names work correctly.
.compare_select => {
if (self.active_tab == .history and self.history_compare_view == null and self.history_table_row_count > 0) {
history_tab.toggleSelectionAt(self, self.history_cursor);
return ctx.consumeAndRedraw();
}
},
.compare_commit => {
if (self.active_tab == .history) {
if (self.history_compare_view != null) {
history_tab.clearCompareState(self);
} else {
history_tab.commitCompareExternal(self);
}
return ctx.consumeAndRedraw();
}
},
.compare_cancel => {
if (self.active_tab == .history) {
history_tab.clearCompareState(self);
return ctx.consumeAndRedraw();
}
},
}
}
/// Returns true if this wheel event should be suppressed (too close to the last one).
fn shouldDebounceWheel(self: *App) bool {
// wall-clock required: input-event debounce needs the actual
// monotonic moment this wheel event arrived, not a frame-captured
// approximation. `.awake` (monotonic) resists system clock jumps.
const now: i128 = @intCast(std.Io.Timestamp.now(self.io, .awake).nanoseconds);
if (now - self.last_wheel_ns < 1 * std.time.ns_per_ms) return true;
self.last_wheel_ns = now;
return false;
}
/// Move cursor/scroll. Positive = down, negative = up.
/// For portfolio and options tabs, moves the row cursor by 1 with
/// debounce to absorb duplicate events from mouse wheel ticks.
/// For other tabs, adjusts scroll_offset by |n|.
fn moveBy(self: *App, n: isize) void {
if (self.active_tab == .portfolio or self.active_tab == .options) {
if (self.shouldDebounceWheel()) return;
if (self.active_tab == .portfolio) {
stepCursor(&self.cursor, self.portfolio_rows.items.len, n);
self.ensureCursorVisible();
} else {
stepCursor(&self.options_cursor, self.options_rows.items.len, n);
self.ensureOptionsCursorVisible();
}
} else if (self.active_tab == .history and self.history_compare_view == null and self.history_table_row_count > 0) {
// Cursor navigation in the recent-snapshots table. Disabled
// during compare view mode (Esc/`c` returns first).
if (self.shouldDebounceWheel()) return;
stepCursor(&self.history_cursor, self.history_table_row_count, n);
self.ensureHistoryCursorVisible();
} else {
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;
}
}
}
fn stepCursor(cursor: *usize, row_count: usize, direction: isize) void {
if (direction > 0) {
if (row_count > 0 and cursor.* < row_count - 1)
cursor.* += 1;
} else {
if (cursor.* > 0) cursor.* -= 1;
}
}
fn ensureCursorVisible(self: *App) void {
const cursor_row = self.cursor + self.portfolio_header_lines;
if (cursor_row < self.scroll_offset) {
self.scroll_offset = cursor_row;
}
const vis: usize = self.visible_height;
if (cursor_row >= self.scroll_offset + vis) {
self.scroll_offset = cursor_row - vis + 1;
}
}
/// Scroll so that the history-tab cursor row is visible. Uses the
/// `history_table_first_line` metadata stashed during the most
/// recent render; safe when it's zero (initial state) because the
/// cursor is also zero then.
pub fn ensureHistoryCursorVisible(self: *App) void {
const cursor_line = self.history_table_first_line + self.history_cursor;
const vis: usize = self.visible_height;
if (cursor_line < self.scroll_offset) {
self.scroll_offset = cursor_line;
} else if (cursor_line >= self.scroll_offset + vis) {
self.scroll_offset = cursor_line - vis + 1;
}
}
fn toggleExpand(self: *App) void {
if (self.portfolio_rows.items.len == 0) return;
if (self.cursor >= self.portfolio_rows.items.len) return;
const row = self.portfolio_rows.items[self.cursor];
switch (row.kind) {
.position => {
// Single-lot positions don't expand
if (row.lot_count <= 1) return;
if (row.pos_idx < self.expanded.len) {
self.expanded[row.pos_idx] = !self.expanded[row.pos_idx];
self.rebuildPortfolioRows();
}
},
.lot, .option_row, .cd_row, .cash_row, .illiquid_row, .section_header, .drip_summary => {},
.cash_total => {
self.cash_expanded = !self.cash_expanded;
self.rebuildPortfolioRows();
},
.illiquid_total => {
self.illiquid_expanded = !self.illiquid_expanded;
self.rebuildPortfolioRows();
},
.watchlist => {
self.setActiveSymbol(row.symbol);
self.active_tab = .quote;
self.loadTabData();
},
}
}
fn toggleOptionsExpand(self: *App) void {
if (self.options_rows.items.len == 0) return;
if (self.options_cursor >= self.options_rows.items.len) return;
const row = self.options_rows.items[self.options_cursor];
switch (row.kind) {
.expiration => {
if (row.exp_idx < self.options_expanded.len) {
self.options_expanded[row.exp_idx] = !self.options_expanded[row.exp_idx];
self.rebuildOptionsRows();
}
},
.calls_header => {
if (row.exp_idx < self.options_calls_collapsed.len) {
self.options_calls_collapsed[row.exp_idx] = !self.options_calls_collapsed[row.exp_idx];
self.rebuildOptionsRows();
}
},
.puts_header => {
if (row.exp_idx < self.options_puts_collapsed.len) {
self.options_puts_collapsed[row.exp_idx] = !self.options_puts_collapsed[row.exp_idx];
self.rebuildOptionsRows();
}
},
// Clicking on a contract does nothing
else => {},
}
}
/// Toggle all calls (is_calls=true) or all puts (is_calls=false) collapsed state.
fn toggleAllCallsPuts(self: *App, is_calls: bool) void {
const chains = self.options_data orelse return;
// Determine whether to collapse or expand: if any expanded chain has this section visible, collapse all; otherwise expand all
var any_visible = false;
for (chains, 0..) |_, ci| {
if (ci >= self.options_expanded.len) break;
if (!self.options_expanded[ci]) continue; // only count expanded expirations
if (is_calls) {
if (ci < self.options_calls_collapsed.len and !self.options_calls_collapsed[ci]) {
any_visible = true;
break;
}
} else {
if (ci < self.options_puts_collapsed.len and !self.options_puts_collapsed[ci]) {
any_visible = true;
break;
}
}
}
// If any are visible, collapse all; otherwise expand all
const new_state = any_visible;
for (chains, 0..) |_, ci| {
if (ci >= 64) break;
if (is_calls) {
self.options_calls_collapsed[ci] = new_state;
} else {
self.options_puts_collapsed[ci] = new_state;
}
}
self.rebuildOptionsRows();
if (is_calls) {
self.setStatus(if (new_state) "All calls collapsed" else "All calls expanded");
} else {
self.setStatus(if (new_state) "All puts collapsed" else "All puts expanded");
}
}
pub fn rebuildOptionsRows(self: *App) void {
self.options_rows.clearRetainingCapacity();
const chains = self.options_data orelse return;
const atm_price = if (chains.len > 0) chains[0].underlying_price orelse 0 else @as(f64, 0);
for (chains, 0..) |chain, ci| {
self.options_rows.append(self.allocator, .{
.kind = .expiration,
.exp_idx = ci,
}) catch continue;
if (ci < self.options_expanded.len and self.options_expanded[ci]) {
// Calls header (always shown, acts as toggle)
self.options_rows.append(self.allocator, .{
.kind = .calls_header,
.exp_idx = ci,
}) catch continue;
// Calls contracts (only if not collapsed)
if (!(ci < self.options_calls_collapsed.len and self.options_calls_collapsed[ci])) {
const filtered_calls = fmt.filterNearMoney(chain.calls, atm_price, self.options_near_the_money);
for (filtered_calls) |cc| {
self.options_rows.append(self.allocator, .{
.kind = .call,
.exp_idx = ci,
.contract = cc,
}) catch continue;
}
}
// Puts header (always shown, acts as toggle)
self.options_rows.append(self.allocator, .{
.kind = .puts_header,
.exp_idx = ci,
}) catch continue;
// Puts contracts (only if not collapsed)
if (!(ci < self.options_puts_collapsed.len and self.options_puts_collapsed[ci])) {
const filtered_puts = fmt.filterNearMoney(chain.puts, atm_price, self.options_near_the_money);
for (filtered_puts) |p| {
self.options_rows.append(self.allocator, .{
.kind = .put,
.exp_idx = ci,
.contract = p,
}) catch continue;
}
}
}
}
}
fn ensureOptionsCursorVisible(self: *App) void {
const cursor_row = self.options_cursor + self.options_header_lines;
if (cursor_row < self.scroll_offset) {
self.scroll_offset = cursor_row;
}
const vis: usize = self.visible_height;
if (cursor_row >= self.scroll_offset + vis) {
self.scroll_offset = cursor_row - vis + 1;
}
}
pub fn setActiveSymbol(self: *App, sym: []const u8) void {
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 {
self.perf_loaded = false;
self.earnings_loaded = false;
self.earnings_disabled = false;
self.earnings_error = null;
self.options_loaded = false;
self.etf_loaded = false;
self.options_cursor = 0;
self.options_expanded = @splat(false);
self.options_calls_collapsed = @splat(false);
self.options_puts_collapsed = @splat(false);
self.options_rows.clearRetainingCapacity();
self.candle_timestamp = 0;
self.options_timestamp = 0;
self.earnings_timestamp = 0;
self.quote = null;
self.quote_timestamp = 0;
self.freeCandles();
self.freeDividends();
self.freeEarnings();
self.freeOptions();
self.freeEtfProfile();
self.trailing_price = null;
self.trailing_total = null;
self.trailing_me_price = null;
self.trailing_me_total = null;
self.risk_metrics = null;
self.scroll_offset = 0;
self.chart.dirty = true;
self.chart.freeCache(self.allocator); // Invalidate indicator cache
}
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.perf_loaded = false;
self.freeCandles();
self.freeDividends();
self.chart.dirty = true;
self.chart.freeCache(self.allocator); // Invalidate indicator cache
},
.earnings => {
self.earnings_loaded = false;
self.earnings_disabled = false;
self.earnings_error = null;
self.freeEarnings();
},
.options => {
self.options_loaded = false;
self.freeOptions();
},
.analysis => {
self.analysis_loaded = false;
if (self.analysis_result) |*ar| ar.deinit(self.allocator);
self.analysis_result = null;
if (self.account_map) |*am| am.deinit();
self.account_map = null;
},
.history => {
self.history_loaded = false;
history_tab.freeLoaded(self);
},
.projections => {
self.projections_loaded = false;
projections_tab.freeLoaded(self);
},
}
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.quote = q;
// wall-clock required: records the exact moment
// this quote was served so the "refreshed Xs ago"
// display is honest about freshness.
self.quote_timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds();
} else |_| {}
}
},
else => {},
}
}
fn loadTabData(self: *App) void {
self.data_error = null;
switch (self.active_tab) {
.portfolio => {
if (!self.portfolio_loaded) self.loadPortfolioData();
},
.quote, .performance => {
if (self.symbol.len == 0) return;
if (!self.perf_loaded) perf_tab.loadData(self);
},
.earnings => {
if (self.symbol.len == 0) return;
if (self.earnings_disabled) return;
if (!self.earnings_loaded) earnings_tab.loadData(self);
},
.options => {
if (self.symbol.len == 0) return;
if (!self.options_loaded) options_tab.loadData(self);
},
.analysis => {
if (self.analysis_disabled) return;
if (!self.analysis_loaded) self.loadAnalysisData();
},
.history => {
if (self.history_disabled) return;
if (!self.history_loaded) history_tab.loadData(self);
},
.projections => {
if (self.projections_disabled) return;
if (!self.projections_loaded) projections_tab.loadData(self);
},
}
}
pub fn loadPortfolioData(self: *App) void {
portfolio_tab.loadPortfolioData(self);
}
fn sortPortfolioAllocations(self: *App) void {
portfolio_tab.sortPortfolioAllocations(self);
}
fn rebuildPortfolioRows(self: *App) void {
portfolio_tab.rebuildPortfolioRows(self);
}
pub fn setStatus(self: *App, msg: []const u8) void {
const len = @min(msg.len, self.status_msg.len);
@memcpy(self.status_msg[0..len], msg[0..len]);
self.status_len = len;
}
fn getStatus(self: *App) []const u8 {
if (self.status_len == 0) return "h/l tabs | j/k select | Enter expand | s select | / symbol | ? help";
return self.status_msg[0..self.status_len];
}
pub fn freeCandles(self: *App) void {
if (self.candles) |c| self.allocator.free(c);
self.candles = null;
}
pub fn freeDividends(self: *App) void {
if (self.dividends) |d| zfin.Dividend.freeSlice(self.allocator, d);
self.dividends = null;
}
pub fn freeEarnings(self: *App) void {
if (self.earnings_data) |e| self.allocator.free(e);
self.earnings_data = null;
}
pub fn freeOptions(self: *App) void {
if (self.options_data) |chains| {
zfin.OptionsChain.freeSlice(self.allocator, chains);
}
self.options_data = null;
}
pub fn freeEtfProfile(self: *App) void {
if (self.etf_profile) |profile| {
if (profile.holdings) |h| {
for (h) |holding| {
if (holding.symbol) |s| self.allocator.free(s);
self.allocator.free(holding.name);
}
self.allocator.free(h);
}
if (profile.sectors) |s| {
for (s) |sec| self.allocator.free(sec.name);
self.allocator.free(s);
}
}
self.etf_profile = null;
self.etf_loaded = false;
}
pub fn freePortfolioSummary(self: *App) void {
if (self.portfolio_summary) |*s| s.deinit(self.allocator);
self.portfolio_summary = null;
}
pub fn freePreparedSections(self: *App) void {
if (self.prepared_options) |*opts| opts.deinit();
self.prepared_options = null;
if (self.prepared_cds) |*cds| cds.deinit();
self.prepared_cds = null;
}
fn deinitData(self: *App) void {
self.freeCandles();
self.freeDividends();
self.freeEarnings();
self.freeOptions();
self.freeEtfProfile();
self.freePortfolioSummary();
self.freePreparedSections();
self.portfolio_rows.deinit(self.allocator);
self.options_rows.deinit(self.allocator);
self.account_list.deinit(self.allocator);
self.account_numbers.deinit(self.allocator);
self.account_shortcut_keys.deinit(self.allocator);
self.account_search_matches.deinit(self.allocator);
if (self.account_filter) |af| self.allocator.free(af);
if (self.filtered_positions) |fp| self.allocator.free(fp);
if (self.watchlist_prices) |*wp| wp.deinit();
if (self.analysis_result) |*ar| ar.deinit(self.allocator);
if (self.classification_map) |*cm| cm.deinit();
if (self.account_map) |*am| am.deinit();
history_tab.freeLoaded(self);
projections_tab.freeLoaded(self);
self.chart.freeCache(self.allocator); // Free cached indicators
}
fn reloadPortfolioFile(self: *App) void {
portfolio_tab.reloadPortfolioFile(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 = t.label();
const is_active = t == self.active_tab;
const is_disabled = self.isTabDisabled(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 = &.{} };
}
fn isTabDisabled(self: *App, t: Tab) bool {
return (t == .earnings and self.earnings_disabled) or
(t == .analysis and self.analysis_disabled) or
(t == .history and self.history_disabled) or
(t == .projections and self.projections_disabled);
}
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.portfolio_rows.items.len == 0) return false;
if (self.cursor >= self.portfolio_rows.items.len) return false;
return std.mem.eql(u8, self.portfolio_rows.items[self.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 portfolio_tab.drawAccountPicker(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 projections_tab.drawContent(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.account_filter != null and self.active_tab == .portfolio) {
const af = self.account_filter.?;
const msg = self.getStatus();
const filter_text = std.fmt.allocPrint(ctx.arena, "{s} [Account: {s}]", .{ msg, af }) catch msg;
for (0..@min(filter_text.len, width)) |i| {
buf[i] = .{ .char = .{ .grapheme = glyph(filter_text[i]) }, .style = status_style };
}
} else {
const msg = self.getStatus();
for (0..@min(msg.len, width)) |i| {
buf[i] = .{ .char = .{ .grapheme = glyph(msg[i]) }, .style = status_style };
}
}
}
return .{ .size = .{ .width = width, .height = 1 }, .widget = self.widget(), .buffer = buf, .children = &.{} };
}
// ── Portfolio content ─────────────────────────────────────────
fn drawPortfolioContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
return portfolio_tab.drawContent(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 options_tab.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 quote_tab.drawContent(self, ctx, buf, width, height);
}
// ── Performance tab ──────────────────────────────────────────
fn buildPerfStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
return perf_tab.buildStyledLines(self, arena);
}
// ── Earnings tab ─────────────────────────────────────────────
fn buildEarningsStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
return earnings_tab.buildStyledLines(self, arena);
}
// ── Analysis tab ────────────────────────────────────────────
pub fn loadAnalysisData(self: *App) void {
analysis_tab.loadData(self);
}
fn buildAnalysisStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
return analysis_tab.buildStyledLines(self, arena);
}
fn buildHistoryStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
return history_tab.buildStyledLines(self, arena);
}
// ── Help ─────────────────────────────────────────────────────
fn buildHelpStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
const th = self.theme;
var lines: std.ArrayList(StyledLine) = .empty;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " zfin TUI -- Keybindings", .style = th.headerStyle() });
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
const actions = comptime std.enums.values(keybinds.Action);
const action_labels = [_][]const u8{
"Quit", "Refresh", "Previous tab", "Next tab",
"Tab 1", "Tab 2", "Tab 3", "Tab 4",
"Tab 5", "Tab 6", "Tab 7", "Scroll down",
"Scroll up", "Scroll to top", "Scroll to bottom", "Page down",
"Page up", "Select next", "Select prev", "Expand/collapse",
"Select symbol", "Change symbol (search)", "This help", "Reload portfolio from disk",
"Toggle all calls (options)", "Toggle all puts (options)", "Filter +/- 1 NTM", "Filter +/- 2 NTM",
"Filter +/- 3 NTM", "Filter +/- 4 NTM", "Filter +/- 5 NTM", "Filter +/- 6 NTM",
"Filter +/- 7 NTM", "Filter +/- 8 NTM", "Filter +/- 9 NTM", "Chart: next timeframe",
"Chart: prev timeframe", "History: cycle metric", "Sort: next column", "Sort: prev column",
"Sort: reverse order", "Account filter (portfolio)",
};
for (actions, 0..) |action, ai| {
var key_strs: [8][]const u8 = undefined;
var key_count: usize = 0;
for (self.keymap.bindings) |b| {
if (b.action == action and key_count < key_strs.len) {
var key_buf: [32]u8 = undefined;
if (keybinds.formatKeyCombo(b.key, &key_buf)) |s| {
key_strs[key_count] = try arena.dupe(u8, s);
key_count += 1;
}
}
}
if (key_count == 0) continue;
var combined_buf: [128]u8 = undefined;
var pos: usize = 0;
for (0..key_count) |ki| {
if (ki > 0) {
if (pos + 2 <= combined_buf.len) {
combined_buf[pos] = ',';
combined_buf[pos + 1] = ' ';
pos += 2;
}
}
const ks = key_strs[ki];
if (pos + ks.len <= combined_buf.len) {
@memcpy(combined_buf[pos..][0..ks.len], ks);
pos += ks.len;
}
}
const label_text = if (ai < action_labels.len) action_labels[ai] else @tagName(action);
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s}", .{ combined_buf[0..pos], label_text }), .style = th.contentStyle() });
}
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " Mouse: click tabs, scroll wheel, click rows", .style = th.mutedStyle() });
try lines.append(arena, .{ .text = " Config: ~/.config/zfin/keys.srf | theme.srf", .style = th.mutedStyle() });
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " Press any key to close.", .style = th.dimStyle() });
return lines.toOwnedSlice(arena);
}
// ── Tab navigation ───────────────────────────────────────────
fn nextTab(self: *App) void {
const idx = @intFromEnum(self.active_tab);
var next_idx = if (idx + 1 < tabs.len) idx + 1 else 0;
// Skip disabled tabs (earnings for ETFs, analysis without portfolio)
var tries: usize = 0;
while (self.isTabDisabled(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.isTabDisabled(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;
_ = portfolio_tab;
_ = quote_tab;
_ = perf_tab;
_ = options_tab;
_ = earnings_tab;
_ = analysis_tab;
_ = history_tab;
_ = projections_tab;
}
/// Entry point for the interactive TUI.
/// `args` contains only command-local tokens (everything after `interactive`).
pub fn run(
io: std.Io,
allocator: std.mem.Allocator,
config: zfin.Config,
global_portfolio_path: ?[]const u8,
global_watchlist_path: ?[]const u8,
args: []const []const u8,
today: zfin.Date,
) !void {
var portfolio_path: ?[]const u8 = global_portfolio_path;
const watchlist_path: ?[]const u8 = global_watchlist_path;
var symbol: []const u8 = "";
var symbol_upper_buf: [32]u8 = undefined;
var has_explicit_symbol = false;
var skip_watchlist = false;
var chart_config: chart.ChartConfig = .{};
var i: usize = 0;
while (i < args.len) : (i += 1) {
if (std.mem.eql(u8, args[i], "--default-keys")) {
try keybinds.printDefaults(io);
return;
} else if (std.mem.eql(u8, args[i], "--default-theme")) {
try theme.printDefaults(io);
return;
} else if (std.mem.eql(u8, args[i], "--symbol") or std.mem.eql(u8, args[i], "-s")) {
if (i + 1 < args.len) {
i += 1;
const len = @min(args[i].len, symbol_upper_buf.len);
_ = std.ascii.upperString(symbol_upper_buf[0..len], args[i][0..len]);
symbol = symbol_upper_buf[0..len];
has_explicit_symbol = true;
skip_watchlist = true;
}
} else if (std.mem.eql(u8, args[i], "--chart")) {
if (i + 1 < args.len) {
i += 1;
if (chart.ChartConfig.parse(args[i])) |cc| {
chart_config = cc;
}
}
} else if (args[i].len > 0 and args[i][0] != '-') {
const len = @min(args[i].len, symbol_upper_buf.len);
_ = std.ascii.upperString(symbol_upper_buf[0..len], args[i][0..len]);
symbol = symbol_upper_buf[0..len];
has_explicit_symbol = true;
}
}
var resolved_pf: ?zfin.Config.ResolvedPath = null;
defer if (resolved_pf) |r| r.deinit(allocator);
if (portfolio_path == null and !has_explicit_symbol) {
if (config.resolveUserFile(io, allocator, zfin.Config.default_portfolio_filename)) |r| {
resolved_pf = r;
portfolio_path = r.path;
}
}
var keymap = blk: {
const home_opt = if (config.environ_map) |em| em.get("HOME") else null;
const home = home_opt orelse break :blk keybinds.defaults();
const keys_path = std.fs.path.join(allocator, &.{ home, ".config", "zfin", "keys.srf" }) catch
break :blk keybinds.defaults();
defer allocator.free(keys_path);
break :blk keybinds.loadFromFile(io, allocator, keys_path) orelse keybinds.defaults();
};
defer keymap.deinit();
const loaded_theme = blk: {
const home_opt = if (config.environ_map) |em| em.get("HOME") else null;
const home = home_opt orelse break :blk theme.default_theme;
const theme_path = std.fs.path.join(allocator, &.{ home, ".config", "zfin", "theme.srf" }) catch
break :blk theme.default_theme;
defer allocator.free(theme_path);
break :blk theme.loadFromFile(io, allocator, theme_path) orelse theme.default_theme;
};
var svc = try allocator.create(zfin.DataService);
defer allocator.destroy(svc);
svc.* = zfin.DataService.init(io, allocator, config);
defer svc.deinit();
var app_inst = try allocator.create(App);
defer allocator.destroy(app_inst);
app_inst.* = .{
.allocator = allocator,
.io = io,
.today = today,
.config = config,
.svc = svc,
.keymap = keymap,
.theme = loaded_theme,
.portfolio_path = portfolio_path,
.symbol = symbol,
.has_explicit_symbol = has_explicit_symbol,
.chart = .{ .config = chart_config },
.history_expanded_buckets = std.AutoHashMap(history_tab.BucketKey, void).init(allocator),
};
defer app_inst.history_expanded_buckets.deinit();
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 = 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;
}
// Disable analysis tab when no portfolio is loaded (analysis requires portfolio)
if (app_inst.portfolio == null) {
app_inst.analysis_disabled = true;
app_inst.projections_disabled = true;
}
// History tab also requires a portfolio path to locate the
// history/ subdirectory.
if (app_inst.portfolio_path == null) {
app_inst.history_disabled = true;
}
// 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) |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.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 != null) {
portfolio_tab.loadPortfolioData(app_inst);
}
}
defer if (app_inst.portfolio) |*pf| pf.deinit();
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.chart.image_id) |id| {
vx_app.vx.freeImage(vx_app.tty.writer(), id);
app_inst.chart.image_id = null;
}
if (app_inst.projections_image_id) |id| {
vx_app.vx.freeImage(vx_app.tty.writer(), id);
app_inst.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 ", Tab.portfolio.label());
try testing.expectEqualStrings(" 6:Analysis ", Tab.analysis.label());
}