zfin/src/tui.zig
2026-03-20 08:51:49 -07:00

1862 lines
78 KiB
Zig

const std = @import("std");
const vaxis = @import("vaxis");
const zfin = @import("root.zig");
const fmt = @import("format.zig");
const cli = @import("commands/common.zig");
const keybinds = @import("tui/keybinds.zig");
const theme_mod = @import("tui/theme.zig");
const chart_mod = @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");
/// 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,
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 ",
};
}
};
const tabs = [_]Tab{ .portfolio, .quote, .performance, .options, .earnings, .analysis };
pub const InputMode = enum {
normal,
symbol_input,
help,
};
pub const StyledLine = struct {
text: []const u8,
style: vaxis.Style,
// Optional per-character style override ranges (for mixed-color lines)
alt_text: ?[]const u8 = null, // text for the gain/loss column
alt_style: ?vaxis.Style = null,
alt_start: usize = 0,
alt_end: usize = 0,
// Optional pre-encoded grapheme array for multi-byte Unicode (e.g. braille charts).
// When set, each element is a grapheme string for one column position.
graphemes: ?[]const []const u8 = null,
// Optional per-cell style array (same length as graphemes). Enables color gradients.
cell_styles: ?[]const vaxis.Style = null,
};
// ── 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,
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_mod.ChartConfig = .{},
timeframe: chart_mod.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_mod.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,
};
/// 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,
config: zfin.Config,
svc: *zfin.DataService,
keymap: keybinds.KeyMap,
theme: theme_mod.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 = [_]bool{false} ** 64, // 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,
portfolio_header_lines: usize = 0, // number of styled lines before data rows
portfolio_line_to_row: [256]usize = [_]usize{0} ** 256, // 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)
// Options navigation (inline expand/collapse like portfolio)
options_cursor: usize = 0, // selected row in flattened options view
options_expanded: [64]bool = [_]bool{false} ** 64, // which expirations are expanded
options_calls_collapsed: [64]bool = [_]bool{false} ** 64, // per-expiration: calls section collapsed
options_puts_collapsed: [64]bool = [_]bool{false} ** 64, // 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,
// 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,
classification_map: ?zfin.classification.ClassificationMap = null,
account_map: ?zfin.analysis.AccountMap = null,
// 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 == .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 {
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 (t == .earnings and self.earnings_disabled) 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_mod.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;
}
}
}
},
else => {},
}
}
/// 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 {
// Cancel input, return to normal mode
if (key.codepoint == vaxis.Key.escape) {
self.mode = .normal;
self.input_len = 0;
self.setStatus("Cancelled");
return ctx.consumeAndRedraw();
}
// Commit: uppercase the input, set as active symbol, switch to quote tab
if (key.codepoint == vaxis.Key.enter) {
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();
}
// Delete last character
if (key.codepoint == vaxis.Key.backspace) {
if (self.input_len > 0) self.input_len -= 1;
return ctx.consumeAndRedraw();
}
// Ctrl+U: clear entire input (readline convention)
if (key.matches('u', .{ .ctrl = true })) {
self.input_len = 0;
return ctx.consumeAndRedraw();
}
// Accept printable ASCII (letters, digits, dots, hyphens, carets for tickers)
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 ctx.consumeAndRedraw();
}
}
fn handleNormalKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void {
// Escape: no special behavior needed (options is now inline)
if (key.codepoint == vaxis.Key.escape) {
return;
}
// 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 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 => {
const idx = @intFromEnum(action) - @intFromEnum(keybinds.Action.tab_1);
if (idx < tabs.len) {
const target = tabs[idx];
if (target == .earnings and self.earnings_disabled) 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();
}
},
.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();
}
},
.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 => {
if (self.active_tab == .portfolio) {
self.portfolio_sort_dir = self.portfolio_sort_dir.flip();
self.sortPortfolioAllocations();
self.rebuildPortfolioRows();
return ctx.consumeAndRedraw();
}
},
}
}
/// 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) {
const now = std.time.nanoTimestamp();
if (now - self.last_wheel_ns < 1 * std.time.ns_per_ms) return;
self.last_wheel_ns = now;
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 (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;
}
}
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.options_loaded = false;
self.etf_loaded = false;
self.options_cursor = 0;
self.options_expanded = [_]bool{false} ** 64;
self.options_calls_collapsed = [_]bool{false} ** 64;
self.options_puts_collapsed = [_]bool{false} ** 64;
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;
}
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 => {},
}
}
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;
},
.earnings => {
self.earnings_loaded = false;
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;
},
}
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;
self.quote_timestamp = std.time.timestamp();
} 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_loaded) self.loadAnalysisData();
},
}
}
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| {
for (chains) |chain| {
self.allocator.free(chain.calls);
self.allocator.free(chain.puts);
self.allocator.free(chain.underlying_symbol);
}
self.allocator.free(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;
}
fn deinitData(self: *App) void {
self.freeCandles();
self.freeDividends();
self.freeEarnings();
self.freeOptions();
self.freeEtfProfile();
self.freePortfolioSummary();
self.portfolio_rows.deinit(self.allocator);
self.options_rows.deinit(self.allocator);
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();
}
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 = t == .earnings and self.earnings_disabled;
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_mod.Theme.vcolor(if (is_selected) th.warning else th.info),
.bg = theme_mod.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 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 {
switch (self.active_tab) {
.portfolio => try self.drawPortfolioContent(ctx.arena, buf, width, height),
.quote => try self.drawQuoteContent(ctx, buf, width, height),
.performance => try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildPerfStyledLines(ctx.arena)),
.options => try self.drawOptionsContent(ctx.arena, buf, width, height),
.earnings => try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildEarningsStyledLines(ctx.arena)),
.analysis => try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildAnalysisStyledLines(ctx.arena)),
}
}
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;
}
}
}
}
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) {
const prompt_style = t.inputStyle();
@memset(buf, .{ .char = .{ .grapheme = " " }, .style = prompt_style });
const prompt = "Symbol: ";
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 };
}
const hint = " Enter=confirm Esc=cancel ";
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 };
}
}
} else {
const status_style = t.statusStyle();
@memset(buf, .{ .char = .{ .grapheme = " " }, .style = status_style });
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);
}
// ── 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", "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",
"Sort: next column", "Sort: prev column", "Sort: reverse order",
};
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;
if (tabs[next_idx] == .earnings and self.earnings_disabled)
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;
if (tabs[prev_idx] == .earnings and self.earnings_disabled)
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_mod.Theme) !void {
var chart = fmt.computeBrailleChart(arena, data, 60, 10, th.positive, th.negative) catch return;
// No deinit needed: arena handles cleanup
const bg = th.bg;
for (0..chart.chart_height) |row| {
const graphemes = try arena.alloc([]const u8, chart.n_cols + 12); // chart + padding + label
const styles = try arena.alloc(vaxis.Style, chart.n_cols + 12);
var gpos: usize = 0;
// 2 leading spaces
graphemes[gpos] = " ";
styles[gpos] = .{ .fg = theme_mod.Theme.vcolor(th.text_muted), .bg = theme_mod.Theme.vcolor(bg) };
gpos += 1;
graphemes[gpos] = " ";
styles[gpos] = styles[0];
gpos += 1;
// Chart columns
for (0..chart.n_cols) |col| {
const pattern = chart.pattern(row, col);
graphemes[gpos] = fmt.brailleGlyph(pattern);
if (pattern != 0) {
styles[gpos] = .{ .fg = theme_mod.Theme.vcolor(chart.col_colors[col]), .bg = theme_mod.Theme.vcolor(bg) };
} else {
styles[gpos] = .{ .fg = theme_mod.Theme.vcolor(bg), .bg = theme_mod.Theme.vcolor(bg) };
}
gpos += 1;
}
// Right-side price labels
if (row == 0) {
const lbl = try std.fmt.allocPrint(arena, " {s}", .{chart.maxLabel()});
for (lbl) |ch| {
if (gpos < graphemes.len) {
graphemes[gpos] = glyph(ch);
styles[gpos] = .{ .fg = theme_mod.Theme.vcolor(th.text_muted), .bg = theme_mod.Theme.vcolor(bg) };
gpos += 1;
}
}
} else if (row == chart.chart_height - 1) {
const lbl = try std.fmt.allocPrint(arena, " {s}", .{chart.minLabel()});
for (lbl) |ch| {
if (gpos < graphemes.len) {
graphemes[gpos] = glyph(ch);
styles[gpos] = .{ .fg = theme_mod.Theme.vcolor(th.text_muted), .bg = theme_mod.Theme.vcolor(bg) };
gpos += 1;
}
}
}
try lines.append(arena, .{
.text = "",
.style = .{ .fg = theme_mod.Theme.vcolor(th.text), .bg = theme_mod.Theme.vcolor(bg) },
.graphemes = graphemes[0..gpos],
.cell_styles = styles[0..gpos],
});
}
// Date axis below chart
{
var start_buf: [7]u8 = undefined;
var end_buf: [7]u8 = undefined;
const start_label = fmt.BrailleChart.fmtShortDate(chart.start_date, &start_buf);
const end_label = fmt.BrailleChart.fmtShortDate(chart.end_date, &end_buf);
const muted_style = vaxis.Style{ .fg = theme_mod.Theme.vcolor(th.text_muted), .bg = theme_mod.Theme.vcolor(bg) };
const date_graphemes = try arena.alloc([]const u8, chart.n_cols + 12);
const date_styles = try arena.alloc(vaxis.Style, chart.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 = chart.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_mod.Theme.vcolor(th.text), .bg = theme_mod.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_mod;
_ = portfolio_tab;
_ = quote_tab;
_ = perf_tab;
_ = options_tab;
_ = earnings_tab;
_ = analysis_tab;
}
/// Entry point for the interactive TUI.
pub fn run(allocator: std.mem.Allocator, config: zfin.Config, args: []const []const u8) !void {
var portfolio_path: ?[]const u8 = null;
var watchlist_path: ?[]const u8 = null;
var symbol: []const u8 = "";
var symbol_upper_buf: [32]u8 = undefined;
var has_explicit_symbol = false;
var skip_watchlist = false;
var chart_config: chart_mod.ChartConfig = .{};
var i: usize = 2;
while (i < args.len) : (i += 1) {
if (std.mem.eql(u8, args[i], "--default-keys")) {
try keybinds.printDefaults();
return;
} else if (std.mem.eql(u8, args[i], "--default-theme")) {
try theme_mod.printDefaults();
return;
} else if (std.mem.eql(u8, args[i], "--portfolio") or std.mem.eql(u8, args[i], "-p")) {
if (i + 1 < args.len) {
i += 1;
portfolio_path = args[i];
}
} else if (std.mem.eql(u8, args[i], "--watchlist") or std.mem.eql(u8, args[i], "-w")) {
if (i + 1 < args.len) {
i += 1;
watchlist_path = args[i];
} else {
watchlist_path = "watchlist.srf";
}
} 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_mod.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;
}
}
if (portfolio_path == null and !has_explicit_symbol) {
if (std.fs.cwd().access("portfolio.srf", .{})) |_| {
portfolio_path = "portfolio.srf";
} else |_| {}
}
var keymap = blk: {
const home = std.process.getEnvVarOwned(allocator, "HOME") catch break :blk keybinds.defaults();
defer allocator.free(home);
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(allocator, keys_path) orelse keybinds.defaults();
};
defer keymap.deinit();
const theme = blk: {
const home = std.process.getEnvVarOwned(allocator, "HOME") catch break :blk theme_mod.default_theme;
defer allocator.free(home);
const theme_path = std.fs.path.join(allocator, &.{ home, ".config", "zfin", "theme.srf" }) catch
break :blk theme_mod.default_theme;
defer allocator.free(theme_path);
break :blk theme_mod.loadFromFile(allocator, theme_path) orelse theme_mod.default_theme;
};
var svc = try allocator.create(zfin.DataService);
defer allocator.destroy(svc);
svc.* = zfin.DataService.init(allocator, config);
defer svc.deinit();
var app_inst = try allocator.create(App);
defer allocator.destroy(app_inst);
app_inst.* = .{
.allocator = allocator,
.config = config,
.svc = svc,
.keymap = keymap,
.theme = theme,
.portfolio_path = portfolio_path,
.symbol = symbol,
.has_explicit_symbol = has_explicit_symbol,
.chart = .{ .config = chart_config },
};
if (portfolio_path) |path| {
const file_data = std.fs.cwd().readFileAlloc(allocator, path, 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 |_| {}
}
}
if (!skip_watchlist) {
const wl_path = watchlist_path orelse blk: {
std.fs.cwd().access("watchlist.srf", .{}) catch break :blk null;
break :blk @as(?[]const u8, "watchlist.srf");
};
if (wl_path) |path| {
app_inst.watchlist = loadWatchlist(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) |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) {
var prices = std.StringHashMap(f64).init(allocator);
var progress = cli.LoadProgress{
.svc = svc,
.color = true,
.index_offset = 0,
.grand_total = total_count,
};
if (syms) |ss| {
const result = svc.loadPrices(ss, &prices, false, progress.callback());
progress.index_offset = stock_count;
if (result.fetched_count > 0 or result.fail_count > 0) {
var msg_buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Loaded {d} symbols ({d} cached, {d} fetched, {d} failed)\n", .{ ss.len, result.cached_count, result.fetched_count, result.fail_count }) catch "Done loading\n";
cli.stderrPrint(msg) catch {};
}
}
// Load watchlist prices
if (watch_syms.items.len > 0) {
_ = svc.loadPrices(watch_syms.items, &prices, false, progress.callback());
}
app_inst.prefetched_prices = prices;
}
}
defer if (app_inst.portfolio) |*pf| pf.deinit();
defer freeWatchlist(allocator, app_inst.watchlist);
defer app_inst.deinitData();
{
var vx_app = try vaxis.vxfw.App.init(allocator);
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;
}
}
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());
}