1866 lines
78 KiB
Zig
1866 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) self.loadPerfData();
|
|
},
|
|
.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 loadPerfData(self: *App) void {
|
|
perf_tab.loadData(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());
|
|
}
|