complete tui.zig architectural refactor

This commit is contained in:
Emil Lerch 2026-05-16 12:01:53 -07:00
parent e301757311
commit 3ff42591ad
Signed by: lobo
GPG key ID: A7B62D657EF764F8
10 changed files with 624 additions and 505 deletions

View file

@ -11,11 +11,12 @@ const chart = @import("tui/chart.zig");
const input_buffer = @import("tui/input_buffer.zig");
/// Single source of truth for tab modules. Each entry is the
/// imported tab module; the field name is the tab's tag (must match
/// the `Tab` enum variant). `TabStates` is derived from this
/// registry at comptime adding a new tab is a single edit here
/// (plus the matching `Tab` enum variant + label, until those are
/// derived too).
/// imported tab module; the field name is the tab's tag. The
/// `Tab` enum, `TabStates`, `tab_labels`, and the `tabs` slice
/// are all derived from this registry at comptime adding a
/// new tab is a single edit (append a field here, declare the
/// tab's `tab` namespace + `State`, and everything else flows
/// from `pub const label` on the tab module).
const tab_modules = .{
.portfolio = @import("tui/portfolio_tab.zig"),
.quote = @import("tui/quote_tab.zig"),
@ -239,147 +240,6 @@ pub fn buildHelpLines(
return lines.toOwnedSlice(arena);
}
// Tab-specific types
// These logically belong to individual tab files, but live here because
// App's struct fields reference them and Zig requires field types to be
// resolved in the same struct definition.
pub const PortfolioSortField = enum {
symbol,
shares,
avg_cost,
price,
market_value,
gain_loss,
weight,
account,
pub fn label(self: PortfolioSortField) []const u8 {
return switch (self) {
.symbol => "Symbol",
.shares => "Shares",
.avg_cost => "Avg Cost",
.price => "Price",
.market_value => "Market Value",
.gain_loss => "Gain/Loss",
.weight => "Weight",
.account => "Account",
};
}
pub fn next(self: PortfolioSortField) ?PortfolioSortField {
const fields = std.meta.fields(PortfolioSortField);
const idx: usize = @intFromEnum(self);
if (idx + 1 >= fields.len) return null;
return @enumFromInt(idx + 1);
}
pub fn prev(self: PortfolioSortField) ?PortfolioSortField {
const idx: usize = @intFromEnum(self);
if (idx == 0) return null;
return @enumFromInt(idx - 1);
}
};
pub const SortDirection = enum {
asc,
desc,
pub fn flip(self: SortDirection) SortDirection {
return if (self == .asc) .desc else .asc;
}
pub fn indicator(self: SortDirection) []const u8 {
return if (self == .asc) "" else "";
}
};
pub const PortfolioRow = struct {
kind: Kind,
symbol: []const u8,
/// For position rows: index into allocations; for lot rows: lot data.
pos_idx: usize = 0,
lot: ?zfin.Lot = null,
/// Number of lots for this symbol (set on position rows)
lot_count: usize = 0,
/// DRIP summary data (for drip_summary rows)
drip_is_lt: bool = false, // true = LT summary, false = ST summary
drip_lot_count: usize = 0,
drip_shares: f64 = 0,
drip_avg_cost: f64 = 0,
drip_date_first: ?zfin.Date = null,
drip_date_last: ?zfin.Date = null,
/// Pre-formatted text from view model (options and CDs)
prepared_text: ?[]const u8 = null,
/// Semantic styles from view model
row_style: fmt.StyleIntent = .normal,
premium_style: fmt.StyleIntent = .normal,
/// Column offset for premium alt-style coloring (options only)
premium_col_start: usize = 0,
const Kind = enum { position, lot, watchlist, section_header, option_row, cd_row, cash_row, cash_total, illiquid_row, illiquid_total, drip_summary };
};
pub const OptionsRowKind = enum { expiration, calls_header, puts_header, call, put };
pub const OptionsRow = struct {
kind: OptionsRowKind,
exp_idx: usize = 0, // index into options_data chains
contract: ?zfin.OptionContract = null,
};
pub const ChartState = struct {
timeframe: chart.Timeframe = .@"1Y",
image_id: ?u32 = null, // currently transmitted Kitty image ID
image_width: u16 = 0, // image width in cells
image_height: u16 = 0, // image height in cells
symbol: [16]u8 = undefined, // symbol the chart was rendered for
symbol_len: usize = 0,
timeframe_rendered: ?chart.Timeframe = null, // timeframe the chart was rendered for
timeframe_row: ?usize = null, // screen row of the timeframe selector (for mouse clicks)
dirty: bool = true, // needs re-render
price_min: f64 = 0,
price_max: f64 = 0,
rsi_latest: ?f64 = null,
// Cached indicator data (persists across frames to avoid recomputation)
cached_indicators: ?chart.CachedIndicators = null,
cache_candle_count: usize = 0, // candle count when cache was computed
cache_timeframe: ?chart.Timeframe = null, // timeframe when cache was computed
cache_last_close: f64 = 0, // last candle's close when cache was computed
/// Free cached indicator memory.
pub fn freeCache(self: *ChartState, alloc: std.mem.Allocator) void {
if (self.cached_indicators) |*cache| {
cache.deinit(alloc);
self.cached_indicators = null;
}
self.cache_candle_count = 0;
self.cache_timeframe = null;
self.cache_last_close = 0;
}
/// Check if cache is valid for the given candle data and timeframe.
pub fn isCacheValid(self: *const ChartState, candles: []const zfin.Candle, timeframe: chart.Timeframe) bool {
if (self.cached_indicators == null) return false;
if (self.cache_timeframe == null or self.cache_timeframe.? != timeframe) return false;
// Slice candles to timeframe (same logic as renderChart)
const max_days = timeframe.tradingDays();
const n = @min(candles.len, max_days);
const data = candles[candles.len - n ..];
if (data.len != self.cache_candle_count) return false;
if (data.len == 0) return false;
// Check if last close changed (detects data refresh)
const last_close = data[data.len - 1].close;
if (@abs(last_close - self.cache_last_close) > 0.0001) return false;
return true;
}
};
/// Per-tab state, owned by `App` and accessed as `app.states.<tab>`.
///
/// Per-tab private state aggregator, derived at comptime from the
@ -587,14 +447,21 @@ pub const PortfolioData = struct {
/// (max of each symbol's last cached candle date). Drives the
/// "as of close on YYYY-MM-DD" line under the portfolio totals.
/// Computed as a side effect of the portfolio-prices loop in
/// `tab_modules.portfolio.loadPortfolioData`; null when no symbols have
/// `App.ensurePortfolioDataLoaded`; null when no symbols have
/// cached candles.
latest_quote_date: ?zfin.Date = null,
/// Prices fetched before the TUI started (with stderr
/// progress). Consumed by the first
/// `App.ensurePortfolioDataLoaded` call to skip redundant
/// network round-trips on startup. Owned here; freed after
/// first consumption.
prefetched_prices: ?std.StringHashMap(f64) = null,
pub fn deinit(self: *PortfolioData, allocator: std.mem.Allocator) void {
if (self.summary) |*s| s.deinit(allocator);
if (self.account_map) |*am| am.deinit();
if (self.watchlist_prices) |*wp| wp.deinit();
if (self.prefetched_prices) |*pp| pp.deinit();
if (self.file) |*pf| pf.deinit();
self.* = .{};
}
@ -613,10 +480,9 @@ pub const PortfolioData = struct {
pub const App = struct {
allocator: std.mem.Allocator,
io: std.Io,
/// Per-tab private state. See `TabStates` above. Tabs that have
/// migrated to the framework own their fields under
/// `app.states.<tab>`; tabs not yet migrated still have their
/// fields directly on App.
/// Per-tab private state. See `TabStates` above. Each tab
/// owns its UI state under `app.states.<tab>` the field
/// name matches the `tab_modules` registry tag.
states: TabStates = .{},
/// Per-symbol shared data (candles, dividends, trailing returns,
/// ETF profile). See `SymbolData` above. Cleared in
@ -1095,22 +961,6 @@ pub const App = struct {
self.portfolio.account_map = self.svc.loadAccountMap(ppath);
}
/// Set or clear the account filter. Owns the string via allocator.
pub fn setAccountFilter(self: *App, name: ?[]const u8) void {
if (self.states.portfolio.account_filter) |old| self.allocator.free(old);
if (self.states.portfolio.filtered_positions) |fp| self.allocator.free(fp);
self.states.portfolio.filtered_positions = null;
if (name) |n| {
self.states.portfolio.account_filter = self.allocator.dupe(u8, n) catch null;
if (self.portfolio.file) |pf| {
self.states.portfolio.filtered_positions = pf.positionsForAccount(self.today, self.allocator, n) catch null;
}
} else {
self.states.portfolio.account_filter = null;
}
}
fn handleNormalKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void {
// Ctrl+L: full screen redraw (standard TUI convention, not configurable)
if (key.codepoint == 'l' and key.mods.ctrl) {
@ -1211,7 +1061,7 @@ pub const App = struct {
return ctx.consumeAndRedraw();
},
.reload_portfolio => {
self.reloadPortfolioFile();
tab_modules.portfolio.reloadPortfolioFile(&self.states.portfolio, self);
return ctx.consumeAndRedraw();
},
}
@ -1349,8 +1199,225 @@ pub const App = struct {
self.dispatchTry("activate", .{});
}
pub fn loadPortfolioData(self: *App) void {
tab_modules.portfolio.loadPortfolioData(&self.states.portfolio, self);
/// Free the cached portfolio summary on `app.portfolio`. Used
/// before re-fetching live prices (the summary is recomputed
/// from the new prices) and from `reload` to drop stale state.
/// `app.portfolio` is App-owned shared state see
/// `PortfolioData` so cleanup belongs here.
/// Ensure App-level portfolio data (`app.portfolio.summary`,
/// `.historical_snapshots`, `.watchlist_prices`,
/// `.latest_quote_date`) is populated. Idempotent checks
/// `app.portfolio.loaded` and returns immediately if so.
///
/// Called by tabs that need portfolio data (portfolio,
/// analysis, history, projections). Each tab's `activate`
/// calls this; it doesn't touch any tab's UI state. The
/// portfolio tab's `activate` does its own UI setup
/// (sortAllocations, buildAccountList, rebuildRows) AFTER
/// this returns.
///
/// On first call, prefers `app.portfolio.prefetched_prices`
/// (populated before TUI startup); on subsequent calls
/// (after refresh has cleared `loaded`), fetches live via
/// `svc.loadPrices`.
///
/// On any error path, sets a status message and returns
/// early. Callers are not expected to inspect a result
/// they read `app.portfolio.summary` after returning and
/// branch on `null`.
pub fn ensurePortfolioDataLoaded(self: *App) void {
if (self.portfolio.loaded) return;
self.portfolio.loaded = true;
self.freePortfolioSummary();
const pf = self.portfolio.file orelse return;
const positions = pf.positions(self.today, self.allocator) catch {
self.setStatus("Error computing positions");
return;
};
defer self.allocator.free(positions);
var prices = std.StringHashMap(f64).init(self.allocator);
defer prices.deinit();
// Only fetch prices for stock/ETF symbols (skip options, CDs, cash)
const syms = pf.stockSymbols(self.allocator) catch {
self.setStatus("Error getting symbols");
return;
};
defer self.allocator.free(syms);
var latest_date: ?zfin.Date = null;
var fail_count: usize = 0;
var fetch_count: usize = 0;
var stale_count: usize = 0;
var failed_syms: [8][]const u8 = undefined;
if (self.portfolio.prefetched_prices) |*pp| {
// Use pre-fetched prices from before TUI started (first load only)
for (syms) |sym| {
if (pp.get(sym)) |price| {
prices.put(sym, price) catch {};
}
}
// Extract watchlist prices
if (self.portfolio.watchlist_prices) |*wp| wp.clearRetainingCapacity() else {
self.portfolio.watchlist_prices = std.StringHashMap(f64).init(self.allocator);
}
var wp = &(self.portfolio.watchlist_prices.?);
var pp_iter = pp.iterator();
while (pp_iter.next()) |entry| {
if (!prices.contains(entry.key_ptr.*)) {
wp.put(entry.key_ptr.*, entry.value_ptr.*) catch {};
}
}
pp.deinit();
self.portfolio.prefetched_prices = null;
} else {
// Live fetch (refresh path) fetch watchlist first, then stock prices
if (self.portfolio.watchlist_prices) |*wp| wp.clearRetainingCapacity() else {
self.portfolio.watchlist_prices = std.StringHashMap(f64).init(self.allocator);
}
var wp = &(self.portfolio.watchlist_prices.?);
if (self.watchlist) |wl| {
for (wl) |sym| {
const result = self.svc.getCandles(sym) catch continue;
defer result.deinit();
if (result.data.len > 0) {
wp.put(sym, result.data[result.data.len - 1].close) catch {};
}
}
}
for (pf.lots) |lot| {
if (lot.security_type == .watch) {
const sym = lot.priceSymbol();
const result = self.svc.getCandles(sym) catch continue;
defer result.deinit();
if (result.data.len > 0) {
wp.put(sym, result.data[result.data.len - 1].close) catch {};
}
}
}
// Fetch stock prices with TUI status-bar progress
const TuiProgress = struct {
app: *App,
failed: *[8][]const u8,
fail_n: usize = 0,
fn onProgress(ctx: *anyopaque, _: usize, _: usize, symbol: []const u8, status: zfin.DataService.SymbolStatus) void {
const s: *@This() = @ptrCast(@alignCast(ctx));
switch (status) {
.fetching => {
var buf: [64]u8 = undefined;
const msg = std.fmt.bufPrint(&buf, "Loading {s}...", .{symbol}) catch "Loading...";
s.app.setStatus(msg);
},
.failed, .failed_used_stale => {
if (s.fail_n < s.failed.len) {
s.failed[s.fail_n] = symbol;
s.fail_n += 1;
}
},
else => {},
}
}
fn callback(s: *@This()) zfin.DataService.ProgressCallback {
return .{
.context = @ptrCast(s),
.on_progress = onProgress,
};
}
};
var tui_progress = TuiProgress{ .app = self, .failed = &failed_syms };
const load_result = self.svc.loadPrices(syms, &prices, false, tui_progress.callback());
latest_date = load_result.latest_date;
fail_count = load_result.fail_count;
fetch_count = load_result.fetched_count;
stale_count = load_result.stale_count;
}
self.portfolio.latest_quote_date = latest_date;
// Build portfolio summary, candle map, and historical snapshots
var pf_data = cli.buildPortfolioData(self.allocator, pf, positions, syms, &prices, self.svc, self.today) catch |err| switch (err) {
error.NoAllocations => {
self.setStatus("No cached prices. Run: zfin perf <SYMBOL> first");
return;
},
error.SummaryFailed => {
self.setStatus("Error computing portfolio summary");
return;
},
else => {
self.setStatus("Error building portfolio data");
return;
},
};
// Transfer ownership: summary stored on App, candle_map freed after snapshots extracted
self.portfolio.summary = pf_data.summary;
self.portfolio.historical_snapshots = pf_data.snapshots;
{
var it = pf_data.candle_map.valueIterator();
while (it.next()) |v| self.allocator.free(v.*);
pf_data.candle_map.deinit();
}
// Show warning if any securities failed to load
if (fail_count > 0) {
var warn_buf: [256]u8 = undefined;
if (fail_count <= 3) {
// Show actual symbol names for easier debugging
var sym_buf: [128]u8 = undefined;
var sym_len: usize = 0;
const show = @min(fail_count, failed_syms.len);
for (0..show) |fi| {
if (sym_len > 0) {
if (sym_len + 2 < sym_buf.len) {
sym_buf[sym_len] = ',';
sym_buf[sym_len + 1] = ' ';
sym_len += 2;
}
}
const s = failed_syms[fi];
const copy_len = @min(s.len, sym_buf.len - sym_len);
@memcpy(sym_buf[sym_len..][0..copy_len], s[0..copy_len]);
sym_len += copy_len;
}
if (stale_count > 0) {
const warn_msg = std.fmt.bufPrint(&warn_buf, "Failed to refresh: {s} (using stale cache)", .{sym_buf[0..sym_len]}) catch "Warning: some securities failed";
self.setStatus(warn_msg);
} else {
const warn_msg = std.fmt.bufPrint(&warn_buf, "Failed to load: {s}", .{sym_buf[0..sym_len]}) catch "Warning: some securities failed";
self.setStatus(warn_msg);
}
} else {
if (stale_count > 0 and stale_count == fail_count) {
const warn_msg = std.fmt.bufPrint(&warn_buf, "{d} symbols failed to refresh (using stale cache) | r/F5 to retry", .{fail_count}) catch "Warning: some securities used stale cache";
self.setStatus(warn_msg);
} else {
const warn_msg = std.fmt.bufPrint(&warn_buf, "Warning: {d} securities failed to load prices", .{fail_count}) catch "Warning: some securities failed";
self.setStatus(warn_msg);
}
}
} else if (fetch_count > 0) {
var info_buf: [128]u8 = undefined;
const info_msg = std.fmt.bufPrint(&info_buf, "Loaded {d} symbols ({d} fetched) | r/F5 to refresh", .{ syms.len, fetch_count }) catch "Loaded | r/F5 to refresh";
self.setStatus(info_msg);
} else {
// Empty status App's getStatus() will fall back to the
// dynamic default hint composed from the active tab's
// status_hints + global keys.
self.setStatus("");
}
}
pub fn freePortfolioSummary(self: *App) void {
if (self.portfolio.summary) |*s| s.deinit(self.allocator);
self.portfolio.summary = null;
}
pub fn setStatus(self: *App, msg: []const u8) void {
@ -1359,6 +1426,27 @@ pub const App = struct {
self.status_len = len;
}
/// Cell pixel size for the active terminal, used by tabs that
/// render bitmap charts via the Kitty graphics protocol. Falls
/// back to (8, 16) when vaxis hasn't reported pixel dimensions
/// yet (terminal didn't answer the size query, or we're early
/// in startup before the first frame).
///
/// Returns the dimensions vaxis itself would put in
/// `DrawContext.cell_size`, so tabs don't have to thread `ctx`
/// through their `drawContent` hook just to size an image.
pub fn cellPixelSize(self: *const App) struct { width: u32, height: u32 } {
const va = self.vx_app orelse return .{ .width = 8, .height = 16 };
const screen = &va.vx.screen;
if (screen.width == 0 or screen.height == 0) return .{ .width = 8, .height = 16 };
const w = screen.width_pix / screen.width;
const h = screen.height_pix / screen.height;
return .{
.width = if (w > 0) @as(u32, w) else 8,
.height = if (h > 0) @as(u32, h) else 16,
};
}
/// Returns the current status message. When no message has been
/// set, builds a dynamic default hint composed from a small set
/// of always-shown global keys plus the active tab's
@ -1413,11 +1501,6 @@ pub const App = struct {
return formatStatusHint(arena, fragments.items);
}
pub fn freePortfolioSummary(self: *App) void {
if (self.portfolio.summary) |*s| s.deinit(self.allocator);
self.portfolio.summary = null;
}
fn deinitData(self: *App) void {
self.symbol_data.deinit(self.allocator);
tab_modules.earnings.tab.deinit(&self.states.earnings, self);
@ -1529,37 +1612,39 @@ pub const App = struct {
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 => {
const lines = try self.buildPerfStyledLines(ctx.arena);
const start = @min(self.scroll_offset, if (lines.len > 0) lines.len - 1 else 0);
try self.drawStyledContent(ctx.arena, buf, width, height, lines[start..]);
},
.options => try self.drawOptionsContent(ctx.arena, buf, width, height),
.earnings => {
const lines = try self.buildEarningsStyledLines(ctx.arena);
const start = @min(self.scroll_offset, if (lines.len > 0) lines.len - 1 else 0);
try self.drawStyledContent(ctx.arena, buf, width, height, lines[start..]);
},
.analysis => {
const lines = try self.buildAnalysisStyledLines(ctx.arena);
const start = @min(self.scroll_offset, if (lines.len > 0) lines.len - 1 else 0);
try self.drawStyledContent(ctx.arena, buf, width, height, lines[start..]);
},
.history => {
const lines = try self.buildHistoryStyledLines(ctx.arena);
const start = @min(self.scroll_offset, if (lines.len > 0) lines.len - 1 else 0);
try self.drawStyledContent(ctx.arena, buf, width, height, lines[start..]);
},
.projections => try tab_modules.projections.drawContent(&self.states.projections, self, ctx, buf, width, height),
}
try self.dispatchDraw(ctx.arena, buf, width, height);
}
return .{ .size = .{ .width = width, .height = height }, .widget = self.widget(), .buffer = buf, .children = &.{} };
}
/// Dispatch the active tab's draw hook. Each tab declares
/// EXACTLY ONE of `buildStyledLines` (line-list rendering;
/// App handles scroll clamping + cell rendering) or
/// `drawContent` (direct buffer; for layouts that don't fit
/// the line-list shape, e.g. Kitty-graphics chart frames).
/// The framework validator (in `tab_framework.zig`) enforces
/// the exactly-one rule at compile time, so the
/// `@hasDecl` branches below are total.
fn dispatchDraw(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
if (std.mem.eql(u8, field.name, @tagName(self.active_tab))) {
const Module = @field(tab_modules, field.name);
const state_ptr = &@field(self.states, field.name);
if (@hasDecl(Module, "drawContent")) {
return Module.drawContent(state_ptr, self, arena, buf, width, height);
}
// buildStyledLines by the validator's exactly-one
// rule, this branch must be reached when drawContent
// isn't declared.
const lines = try Module.buildStyledLines(state_ptr, self, arena);
const start = @min(self.scroll_offset, if (lines.len > 0) lines.len - 1 else 0);
return self.drawStyledContent(arena, buf, width, height, lines[start..]);
}
}
}
pub fn drawStyledContent(_: *App, _: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16, lines: []const StyledLine) !void {
for (lines, 0..) |line, row| {
if (row >= height) break;
@ -1695,48 +1780,6 @@ pub const App = struct {
return null;
}
// Portfolio content
fn drawPortfolioContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
return tab_modules.portfolio.drawContent(&self.states.portfolio, self, arena, buf, width, height);
}
// Options content (with cursor/scroll)
fn drawOptionsContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
const styled_lines = try tab_modules.options.buildStyledLines(self, arena);
const start = @min(self.scroll_offset, if (styled_lines.len > 0) styled_lines.len - 1 else 0);
try self.drawStyledContent(arena, buf, width, height, styled_lines[start..]);
}
// Quote tab
fn drawQuoteContent(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void {
return tab_modules.quote.drawContent(self, ctx, buf, width, height);
}
// Performance tab
fn buildPerfStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
return tab_modules.performance.buildStyledLines(self, arena);
}
// Earnings tab
fn buildEarningsStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
return tab_modules.earnings.buildStyledLines(self, arena);
}
// Analysis tab
fn buildAnalysisStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
return tab_modules.analysis.buildStyledLines(self, arena);
}
fn buildHistoryStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
return tab_modules.history.buildStyledLines(&self.states.history, self, arena);
}
// Help
fn buildHelpStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
@ -2208,16 +2251,18 @@ pub fn run(
false, // force_refresh
true, // color
);
app_inst.states.portfolio.prefetched_prices = load_result.prices;
app_inst.portfolio.prefetched_prices = load_result.prices;
}
// Eagerly compute PortfolioData so the history-tab's live
// pseudo-row + compare-to-live-now works from first render,
// without requiring the user to visit the portfolio tab
// first. Cheap (pure compute + cache reads) once prices are
// already in hand.
// Pre-load PortfolioData while the terminal is still in
// normal mode `loadPrices` emits stderr progress that
// would be invisible after vaxis takes over the screen.
// Each tab that needs the data also calls
// `ensurePortfolioDataLoaded` from its `activate`
// (idempotent), so this is a UX optimization, not a
// correctness requirement.
if (app_inst.portfolio.file != null) {
tab_modules.portfolio.loadPortfolioData(&app_inst.states.portfolio, app_inst);
app_inst.ensurePortfolioDataLoaded();
}
}
@ -2291,29 +2336,6 @@ test "glyph non-ASCII returns space" {
try testing.expectEqualStrings(" ", glyph(200));
}
test "PortfolioSortField next/prev" {
// next from first field
try testing.expectEqual(PortfolioSortField.shares, PortfolioSortField.symbol.next().?);
// next from last field returns null
try testing.expectEqual(@as(?PortfolioSortField, null), PortfolioSortField.account.next());
// prev from first returns null
try testing.expectEqual(@as(?PortfolioSortField, null), PortfolioSortField.symbol.prev());
// prev from last
try testing.expectEqual(PortfolioSortField.weight, PortfolioSortField.account.prev().?);
}
test "PortfolioSortField label" {
try testing.expectEqualStrings("Symbol", PortfolioSortField.symbol.label());
try testing.expectEqualStrings("Market Value", PortfolioSortField.market_value.label());
}
test "SortDirection flip and indicator" {
try testing.expectEqual(SortDirection.desc, SortDirection.asc.flip());
try testing.expectEqual(SortDirection.asc, SortDirection.desc.flip());
try testing.expectEqualStrings("\xe2\x96\xb2", SortDirection.asc.indicator()); //
try testing.expectEqualStrings("\xe2\x96\xbc", SortDirection.desc.indicator()); //
}
test "Tab label" {
try testing.expectEqualStrings(" 1:Portfolio ", tabLabel(.portfolio));
try testing.expectEqualStrings(" 6:Analysis ", tabLabel(.analysis));

View file

@ -105,7 +105,7 @@ fn loadData(state: *State, app: *App) void {
state.loaded = true;
// Ensure portfolio is loaded first
if (!app.portfolio.loaded) app.loadPortfolioData();
app.ensurePortfolioDataLoaded();
const pf = app.portfolio.file orelse return;
const summary = app.portfolio.summary orelse return;
@ -162,8 +162,7 @@ fn loadDataFinish(state: *State, app: *App, pf: zfin.Portfolio, summary: zfin.va
// Rendering
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
const state = &app.states.analysis;
pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine {
// Compute equity/fixed split from classification + portfolio
var stock_pct: f64 = 0;
var bond_pct: f64 = 0;

View file

@ -179,8 +179,7 @@ fn loadData(state: *State, app: *App) void {
// Rendering
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
const state = &app.states.earnings;
pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine {
// wall-clock required: per-frame "now" for the earnings
// "data Xs ago" readout. Captured here so the pure renderer below
// stays free of io.

View file

@ -202,6 +202,10 @@ pub const tab = struct {
pub fn activate(state: *State, app: *App) !void {
if (state.loaded) return;
// History reads `app.portfolio.summary` and `.file`.
// Ensure they're populated even when the user jumps
// straight here without visiting portfolio first.
app.ensurePortfolioDataLoaded();
loadData(state, app);
}

View file

@ -9,7 +9,18 @@ const framework = @import("tab_framework.zig");
const App = tui.App;
const StyledLine = tui.StyledLine;
const OptionsRow = tui.OptionsRow;
/// One row in the options tab's flattened display list. Mix of
/// expiration-group headers, calls/puts section headers, and
/// individual contract rows; rebuilt by `rebuildRows` whenever
/// expansion state changes.
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,
};
// Tab-local action enum
//
@ -452,8 +463,7 @@ pub fn formatUnderlyingHeader(
// Rendering
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
const state = &app.states.options;
pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine {
const th = app.theme;
var lines: std.ArrayList(StyledLine) = .empty;
@ -630,8 +640,8 @@ test "rebuildRows: collapsed expirations emit only the expiration rows" {
defer state.rows.deinit(testing.allocator);
// Two collapsed expirations = two rows.
try testing.expectEqual(@as(usize, 2), state.rows.items.len);
try testing.expectEqual(tui.OptionsRowKind.expiration, state.rows.items[0].kind);
try testing.expectEqual(tui.OptionsRowKind.expiration, state.rows.items[1].kind);
try testing.expectEqual(OptionsRowKind.expiration, state.rows.items[0].kind);
try testing.expectEqual(OptionsRowKind.expiration, state.rows.items[1].kind);
}
test "rebuildRows: expanded expiration emits headers + filtered contracts" {

View file

@ -182,7 +182,8 @@ pub fn formatPerformanceHeader(
std.fmt.allocPrint(arena, " Trailing Returns: {s}", .{symbol});
}
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine {
_ = state;
const th = app.theme;
var lines: std.ArrayList(StyledLine) = .empty;

View file

@ -13,9 +13,6 @@ const framework = @import("tab_framework.zig");
const App = tui.App;
const StyledLine = tui.StyledLine;
const PortfolioRow = tui.PortfolioRow;
const PortfolioSortField = tui.PortfolioSortField;
const SortDirection = tui.SortDirection;
const colLabel = tui.colLabel;
const glyph = tui.glyph;
@ -25,6 +22,94 @@ const glyph = tui.glyph;
const prefix_cols: usize = 4;
const sw: usize = fmt.sym_col_width;
// Portfolio-specific types
/// Sortable columns in the portfolio table. Bound to the
/// `sort_col_next` / `sort_col_prev` actions; the `sort_reverse`
/// action flips the current `SortDirection`.
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);
}
};
/// Sort direction for the portfolio table.
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 "";
}
};
/// One row in the portfolio table's flattened display list.
/// Covers position rows, lot rows (when expanded), watchlist
/// entries, section headers, options/CDs/cash/illiquid summary
/// rows, and DRIP-summary rows. Rebuilt by
/// `rebuildPortfolioRows` whenever sort / filter / expansion
/// changes.
pub const PortfolioRow = struct {
kind: Kind,
symbol: []const u8,
/// For position rows: index into allocations; for lot rows: lot data.
pos_idx: usize = 0,
lot: ?zfin.Lot = null,
/// Number of lots for this symbol (set on position rows)
lot_count: usize = 0,
/// DRIP summary data (for drip_summary rows)
drip_is_lt: bool = false, // true = LT summary, false = ST summary
drip_lot_count: usize = 0,
drip_shares: f64 = 0,
drip_avg_cost: f64 = 0,
drip_date_first: ?zfin.Date = null,
drip_date_last: ?zfin.Date = null,
/// Pre-formatted text from view model (options and CDs)
prepared_text: ?[]const u8 = null,
/// Semantic styles from view model
row_style: fmt.StyleIntent = .normal,
premium_style: fmt.StyleIntent = .normal,
/// Column offset for premium alt-style coloring (options only)
premium_col_start: usize = 0,
const Kind = enum { position, lot, watchlist, section_header, option_row, cd_row, cash_row, cash_total, illiquid_row, illiquid_total, drip_summary };
};
// Tab-local action enum
//
// Portfolio tab keybinds (today routed through legacy global
@ -130,12 +215,6 @@ pub const State = struct {
/// Auto-assigned shortcut-key per account, parallel to
/// `account_list`. Used by the account picker modal.
account_shortcut_keys: std.ArrayList(u8) = .empty,
/// Prefetched prices populated before the TUI started (with
/// stderr progress). Consumed by the first `loadPortfolioData`
/// call to skip redundant network round-trips. Owned by State;
/// freed after first consumption.
prefetched_prices: ?std.StringHashMap(f64) = null,
// Account picker / search modal
//
// The portfolio tab owns picker state in full: the cursor,
@ -227,7 +306,13 @@ pub const tab = struct {
}
pub fn activate(state: *State, app: *App) !void {
if (app.portfolio.loaded) return;
// `loadPortfolioData` calls `ensurePortfolioDataLoaded`
// (idempotent short-circuits when data is already
// loaded) and then unconditionally rebuilds the
// portfolio-tab UI state (sort, account list, rows).
// Skipping the UI rebuild on cache hit would leave a
// freshly-activated tab with no rows when the data was
// pre-loaded by App startup or another tab.
loadPortfolioData(state, app);
}
@ -288,7 +373,7 @@ pub const tab = struct {
.clear_account_filter => {
// No-op when no filter is active.
if (state.account_filter == null) return;
app.setAccountFilter(null);
setAccountFilter(state, app, null);
state.cursor = 0;
app.scroll_offset = 0;
rebuildPortfolioRows(state, app);
@ -493,7 +578,7 @@ pub const col_end_date: usize = col_end_weight + 14;
const gl_col_start: usize = col_end_market_value;
/// Map a semantic StyleIntent to a platform-specific vaxis style.
fn mapIntent(th: anytype, intent: fmt.StyleIntent) @import("vaxis").Style {
fn mapIntent(th: theme.Theme, intent: fmt.StyleIntent) vaxis.Style {
return th.styleFor(intent);
}
@ -509,212 +594,56 @@ fn mapIntent(th: anytype, intent: fmt.StyleIntent) @import("vaxis").Style {
/// On first call, uses prefetched_prices (populated before TUI started).
/// On refresh, fetches live via svc.loadPrices. Tab switching skips this
/// entirely because the portfolio_loaded guard in loadTabData() short-circuits.
/// Set up the portfolio tab's UI state from current
/// `app.portfolio` data: sort allocations per current sort
/// field, build the account list, recompute filtered positions
/// (when an account filter is active), and rebuild the styled
/// row list. Called from `activate` AFTER
/// `app.ensurePortfolioDataLoaded()`. No-op when no summary is
/// available.
///
/// Call paths:
/// 1. First tab visit: `tab.activate` here
/// 2. Manual refresh (r/F5): `tab.reload` clears
/// `app.portfolio.loaded` `tab.activate` ensurePortfolioDataLoaded here
/// 3. Disk reload (R): `reloadPortfolioFile` separate
/// function, cache-only, no network
///
/// Tab switching skips this entirely because `tab.activate`'s
/// own guard short-circuits when `state.loaded` (TODO: this
/// flag doesn't exist yet on portfolio.State; today the guard
/// is `app.portfolio.loaded` which `ensurePortfolioDataLoaded`
/// owns. Visiting portfolio after analysis pre-loaded the data
/// will still rebuild the row list cheap.)
pub fn loadPortfolioData(state: *State, app: *App) void {
app.portfolio.loaded = true;
app.freePortfolioSummary();
app.ensurePortfolioDataLoaded();
const pf = app.portfolio.file orelse return;
const positions = pf.positions(app.today, app.allocator) catch {
app.setStatus("Error computing positions");
return;
};
defer app.allocator.free(positions);
var prices = std.StringHashMap(f64).init(app.allocator);
defer prices.deinit();
// Only fetch prices for stock/ETF symbols (skip options, CDs, cash)
const syms = pf.stockSymbols(app.allocator) catch {
app.setStatus("Error getting symbols");
return;
};
defer app.allocator.free(syms);
var latest_date: ?zfin.Date = null;
var fail_count: usize = 0;
var fetch_count: usize = 0;
var stale_count: usize = 0;
var failed_syms: [8][]const u8 = undefined;
if (state.prefetched_prices) |*pp| {
// Use pre-fetched prices from before TUI started (first load only)
// Move stock prices into the working map
for (syms) |sym| {
if (pp.get(sym)) |price| {
prices.put(sym, price) catch {};
}
}
// Extract watchlist prices
if (app.portfolio.watchlist_prices) |*wp| wp.clearRetainingCapacity() else {
app.portfolio.watchlist_prices = std.StringHashMap(f64).init(app.allocator);
}
var wp = &(app.portfolio.watchlist_prices.?);
var pp_iter = pp.iterator();
while (pp_iter.next()) |entry| {
if (!prices.contains(entry.key_ptr.*)) {
wp.put(entry.key_ptr.*, entry.value_ptr.*) catch {};
}
}
pp.deinit();
state.prefetched_prices = null;
} else {
// Live fetch (refresh path) fetch watchlist first, then stock prices
if (app.portfolio.watchlist_prices) |*wp| wp.clearRetainingCapacity() else {
app.portfolio.watchlist_prices = std.StringHashMap(f64).init(app.allocator);
}
var wp = &(app.portfolio.watchlist_prices.?);
if (app.watchlist) |wl| {
for (wl) |sym| {
const result = app.svc.getCandles(sym) catch continue;
defer result.deinit();
if (result.data.len > 0) {
wp.put(sym, result.data[result.data.len - 1].close) catch {};
}
}
}
for (pf.lots) |lot| {
if (lot.security_type == .watch) {
const sym = lot.priceSymbol();
const result = app.svc.getCandles(sym) catch continue;
defer result.deinit();
if (result.data.len > 0) {
wp.put(sym, result.data[result.data.len - 1].close) catch {};
}
}
}
// Fetch stock prices with TUI status-bar progress
const TuiProgress = struct {
app: *App,
failed: *[8][]const u8,
fail_n: usize = 0,
fn onProgress(ctx: *anyopaque, _: usize, _: usize, symbol: []const u8, status: zfin.DataService.SymbolStatus) void {
const s: *@This() = @ptrCast(@alignCast(ctx));
switch (status) {
.fetching => {
var buf: [64]u8 = undefined;
const msg = std.fmt.bufPrint(&buf, "Loading {s}...", .{symbol}) catch "Loading...";
s.app.setStatus(msg);
},
.failed, .failed_used_stale => {
if (s.fail_n < s.failed.len) {
s.failed[s.fail_n] = symbol;
s.fail_n += 1;
}
},
else => {},
}
}
fn callback(s: *@This()) zfin.DataService.ProgressCallback {
return .{
.context = @ptrCast(s),
.on_progress = onProgress,
};
}
};
var tui_progress = TuiProgress{ .app = app, .failed = &failed_syms };
const load_result = app.svc.loadPrices(syms, &prices, false, tui_progress.callback());
latest_date = load_result.latest_date;
fail_count = load_result.fail_count;
fetch_count = load_result.fetched_count;
stale_count = load_result.stale_count;
}
app.portfolio.latest_quote_date = latest_date;
// Build portfolio summary, candle map, and historical snapshots
var pf_data = cli.buildPortfolioData(app.allocator, pf, positions, syms, &prices, app.svc, app.today) catch |err| switch (err) {
error.NoAllocations => {
app.setStatus("No cached prices. Run: zfin perf <SYMBOL> first");
return;
},
error.SummaryFailed => {
app.setStatus("Error computing portfolio summary");
return;
},
else => {
app.setStatus("Error building portfolio data");
return;
},
};
// Transfer ownership: summary stored on App, candle_map freed after snapshots extracted
app.portfolio.summary = pf_data.summary;
app.portfolio.historical_snapshots = pf_data.snapshots;
{
// Free candle_map values and map (snapshots are value types, already copied)
var it = pf_data.candle_map.valueIterator();
while (it.next()) |v| app.allocator.free(v.*);
pf_data.candle_map.deinit();
}
// App may have failed to load check before touching summary.
const summary = app.portfolio.summary orelse return;
sortPortfolioAllocations(state, app);
buildAccountList(state, app);
recomputeFilteredPositions(state, app);
rebuildPortfolioRows(state, app);
const summary = pf_data.summary;
// Pre-select the first row when no symbol is active yet.
// Runs AFTER `sortPortfolioAllocations` so the default
// matches what the user sees at the top of the table
// alphabetically first by symbol with the default sort,
// not whatever lot happens to appear first in
// `portfolio.srf`. This is the "user just started the TUI;
// pick something sensible" path; once `app.symbol` is set
// (by user action or `--symbol`), this is a no-op.
if (app.symbol.len == 0 and summary.allocations.len > 0) {
app.setActiveSymbol(summary.allocations[0].symbol);
}
// Show warning if any securities failed to load
if (fail_count > 0) {
var warn_buf: [256]u8 = undefined;
if (fail_count <= 3) {
// Show actual symbol names for easier debugging
var sym_buf: [128]u8 = undefined;
var sym_len: usize = 0;
const show = @min(fail_count, failed_syms.len);
for (0..show) |fi| {
if (sym_len > 0) {
if (sym_len + 2 < sym_buf.len) {
sym_buf[sym_len] = ',';
sym_buf[sym_len + 1] = ' ';
sym_len += 2;
}
}
const s = failed_syms[fi];
const copy_len = @min(s.len, sym_buf.len - sym_len);
@memcpy(sym_buf[sym_len..][0..copy_len], s[0..copy_len]);
sym_len += copy_len;
}
if (stale_count > 0) {
const warn_msg = std.fmt.bufPrint(&warn_buf, "Failed to refresh: {s} (using stale cache)", .{sym_buf[0..sym_len]}) catch "Warning: some securities failed";
app.setStatus(warn_msg);
} else {
const warn_msg = std.fmt.bufPrint(&warn_buf, "Failed to load: {s}", .{sym_buf[0..sym_len]}) catch "Warning: some securities failed";
app.setStatus(warn_msg);
}
} else {
if (stale_count > 0 and stale_count == fail_count) {
const warn_msg = std.fmt.bufPrint(&warn_buf, "{d} symbols failed to refresh (using stale cache) | r/F5 to retry", .{fail_count}) catch "Warning: some securities used stale cache";
app.setStatus(warn_msg);
} else {
const warn_msg = std.fmt.bufPrint(&warn_buf, "Warning: {d} securities failed to load prices", .{fail_count}) catch "Warning: some securities failed";
app.setStatus(warn_msg);
}
}
} else if (fetch_count > 0) {
var info_buf: [128]u8 = undefined;
const info_msg = std.fmt.bufPrint(&info_buf, "Loaded {d} symbols ({d} fetched) | r/F5 to refresh", .{ syms.len, fetch_count }) catch "Loaded | r/F5 to refresh";
app.setStatus(info_msg);
} else {
// Empty status App's getStatus() will fall back to the
// dynamic default hint composed from the active tab's
// status_hints + global keys.
app.setStatus("");
}
}
pub fn sortPortfolioAllocations(state: *State, app: *App) void {
if (app.portfolio.summary) |s| {
const SortCtx = struct {
field: PortfolioSortField,
dir: tui.SortDirection,
dir: SortDirection,
fn lessThan(ctx: @This(), a: zfin.valuation.Allocation, b: zfin.valuation.Allocation) bool {
const lhs = if (ctx.dir == .asc) a else b;
@ -1012,6 +941,26 @@ pub fn rebuildPortfolioRows(state: *State, app: *App) void {
}
}
/// Set or clear the account filter on portfolio.State. Owns
/// the filter string via allocator (dup on set, free on
/// clear/replace) and recomputes `filtered_positions` from
/// `app.portfolio.file` so subsequent renders don't have to
/// re-iterate lots. Pass `null` to clear.
pub fn setAccountFilter(state: *State, app: *App, name: ?[]const u8) void {
if (state.account_filter) |old| app.allocator.free(old);
if (state.filtered_positions) |fp| app.allocator.free(fp);
state.filtered_positions = null;
if (name) |n| {
state.account_filter = app.allocator.dupe(u8, n) catch null;
if (app.portfolio.file) |pf| {
state.filtered_positions = pf.positionsForAccount(app.today, app.allocator, n) catch null;
}
} else {
state.account_filter = null;
}
}
/// Build the ordered list of distinct account names from portfolio lots.
/// Order: accounts.srf file order first, then any remaining accounts alphabetically.
/// Also assigns shortcut keys and loads account numbers from accounts.srf.
@ -1088,7 +1037,7 @@ pub fn buildAccountList(state: *State, app: *App) void {
break;
}
}
if (!found) app.setAccountFilter(null);
if (!found) setAccountFilter(state, app, null);
}
}
@ -2205,11 +2154,11 @@ fn containsLower(haystack: []const u8, needle_lower: []const u8) bool {
fn applyAccountPickerSelection(state: *State, app: *App) void {
if (state.account_picker_cursor == 0) {
// "All accounts" clear filter
app.setAccountFilter(null);
setAccountFilter(state, app, null);
} else {
const idx = state.account_picker_cursor - 1;
if (idx < state.account_list.items.len) {
app.setAccountFilter(state.account_list.items[idx]);
setAccountFilter(state, app, state.account_list.items[idx]);
}
}
state.modal = .none;
@ -2230,6 +2179,29 @@ fn applyAccountPickerSelection(state: *State, app: *App) void {
const testing = std.testing;
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 "buildWelcomeScreenLines: includes resolved keys in expected slots" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();

View file

@ -192,6 +192,10 @@ pub const tab = struct {
pub fn activate(state: *State, app: *App) !void {
if (state.loaded) return;
// Projections reads `app.portfolio.summary` and
// `.file`. Ensure they're populated even when the user
// jumps straight here without visiting portfolio first.
app.ensurePortfolioDataLoaded();
loadData(state, app);
}
@ -533,8 +537,7 @@ pub fn freeLoaded(state: *State, app: *App) void {
// Rendering
pub fn drawContent(state: *State, app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void {
const arena = ctx.arena;
pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
// Determine whether to use Kitty graphics
const use_kitty = switch (app.chart_config.mode) {
@ -555,7 +558,7 @@ pub fn drawContent(state: *State, app: *App, ctx: vaxis.vxfw.DrawContext, buf: [
} else false;
if (use_kitty and has_bands and state.chart_visible) {
drawWithKittyChart(state, app, ctx, buf, width, height) catch {
drawWithKittyChart(state, app, arena, buf, width, height) catch {
try drawWithScroll(state, app, arena, buf, width, height);
};
} else {
@ -565,14 +568,13 @@ pub fn drawContent(state: *State, app: *App, ctx: vaxis.vxfw.DrawContext, buf: [
/// Render styled lines with scroll_offset applied.
fn drawWithScroll(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
const all_lines = try buildStyledLines(state, app, arena);
const all_lines = try buildLines(state, app, arena);
const start = @min(app.scroll_offset, if (all_lines.len > 0) all_lines.len - 1 else 0);
try app.drawStyledContent(arena, buf, width, height, all_lines[start..]);
}
/// Draw projections tab using Kitty graphics protocol for the percentile band chart.
fn drawWithKittyChart(state: *State, app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void {
const arena = ctx.arena;
fn drawWithKittyChart(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
const th = app.theme;
const pctx = state.ctx orelse return;
const config = pctx.config;
@ -613,8 +615,9 @@ fn drawWithKittyChart(state: *State, app: *App, ctx: vaxis.vxfw.DrawContext, buf
}
// Compute pixel dimensions
const cell_w: u32 = if (ctx.cell_size.width > 0) ctx.cell_size.width else 8;
const cell_h: u32 = if (ctx.cell_size.height > 0) ctx.cell_size.height else 16;
const cell_size = app.cellPixelSize();
const cell_w: u32 = cell_size.width;
const cell_h: u32 = cell_size.height;
const label_cols: u16 = 12; // columns for axis labels on the right
const chart_cols = width -| 2 -| label_cols;
if (chart_cols == 0) return;
@ -1154,7 +1157,12 @@ fn appendAccumulationBlocks(
}
}
pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine {
/// Build the styled-line representation of the projections
/// view (text-only fallback when the chart is hidden, and the
/// scroll body when the chart is visible). File-private the
/// framework draw hook is `drawContent`, which composes this
/// internally.
fn buildLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine {
const th = app.theme;
var lines: std.ArrayListUnmanaged(StyledLine) = .empty;

View file

@ -12,6 +12,62 @@ const App = tui.App;
const StyledLine = tui.StyledLine;
const glyph = tui.glyph;
/// Per-symbol chart state for the quote tab. Tracks the active
/// timeframe, transmitted Kitty image (when supported), cached
/// indicator overlays (SMA/Bollinger/etc), and last-rendered
/// data fingerprints used to decide when to re-render.
pub const ChartState = struct {
timeframe: chart.Timeframe = .@"1Y",
image_id: ?u32 = null, // currently transmitted Kitty image ID
image_width: u16 = 0, // image width in cells
image_height: u16 = 0, // image height in cells
symbol: [16]u8 = undefined, // symbol the chart was rendered for
symbol_len: usize = 0,
timeframe_rendered: ?chart.Timeframe = null, // timeframe the chart was rendered for
timeframe_row: ?usize = null, // screen row of the timeframe selector (for mouse clicks)
dirty: bool = true, // needs re-render
price_min: f64 = 0,
price_max: f64 = 0,
rsi_latest: ?f64 = null,
// Cached indicator data (persists across frames to avoid recomputation)
cached_indicators: ?chart.CachedIndicators = null,
cache_candle_count: usize = 0, // candle count when cache was computed
cache_timeframe: ?chart.Timeframe = null, // timeframe when cache was computed
cache_last_close: f64 = 0, // last candle's close when cache was computed
/// Free cached indicator memory.
pub fn freeCache(self: *ChartState, alloc: std.mem.Allocator) void {
if (self.cached_indicators) |*cache| {
cache.deinit(alloc);
self.cached_indicators = null;
}
self.cache_candle_count = 0;
self.cache_timeframe = null;
self.cache_last_close = 0;
}
/// Check if cache is valid for the given candle data and timeframe.
pub fn isCacheValid(self: *const ChartState, candles: []const zfin.Candle, timeframe: chart.Timeframe) bool {
if (self.cached_indicators == null) return false;
if (self.cache_timeframe == null or self.cache_timeframe.? != timeframe) return false;
// Slice candles to timeframe (same logic as renderChart)
const max_days = timeframe.tradingDays();
const n = @min(candles.len, max_days);
const data = candles[candles.len - n ..];
if (data.len != self.cache_candle_count) return false;
if (data.len == 0) return false;
// Check if last close changed (detects data refresh)
const last_close = data[data.len - 1].close;
if (@abs(last_close - self.cache_last_close) > 0.0001) return false;
return true;
}
};
// Tab-local action enum
//
// Quote tab cycles the chart timeframe with `[` and `]` (chart-
@ -37,7 +93,7 @@ pub const State = struct {
/// indicator cache + timeframe selection). Lives here because
/// only the quote tab uses it; perf renders its own braille
/// chart from `app.symbol_data.candles` directly.
chart: tui.ChartState = .{},
chart: ChartState = .{},
};
// Tab framework contract
@ -181,8 +237,8 @@ pub const tab = struct {
/// Draw the quote tab content. Uses Kitty graphics for the chart when available,
/// falling back to braille sparkline otherwise.
pub fn drawContent(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void {
const arena = ctx.arena;
pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
_ = state;
// Determine whether to use Kitty graphics
const use_kitty = switch (app.chart_config.mode) {
@ -192,7 +248,7 @@ pub fn drawContent(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, wi
};
if (use_kitty and app.symbol_data.candles != null and app.symbol_data.candles.?.len >= 40) {
drawWithKittyChart(app, ctx, buf, width, height) catch {
drawWithKittyChart(app, arena, buf, width, height) catch {
// On any failure, fall back to braille
try app.drawStyledContent(arena, buf, width, height, try buildStyledLines(app, arena));
};
@ -203,8 +259,7 @@ pub fn drawContent(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, wi
}
/// Draw quote tab using Kitty graphics protocol for the chart.
fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void {
const arena = ctx.arena;
fn drawWithKittyChart(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
const th = app.theme;
const c = app.symbol_data.candles orelse return;
@ -286,8 +341,9 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
// Compute pixel dimensions from cell size
// cell_size may be 0 if terminal hasn't reported pixel dimensions yet
const cell_w: u32 = if (ctx.cell_size.width > 0) ctx.cell_size.width else 8;
const cell_h: u32 = if (ctx.cell_size.height > 0) ctx.cell_size.height else 16;
const cell_size = app.cellPixelSize();
const cell_w: u32 = cell_size.width;
const cell_h: u32 = cell_size.height;
const label_cols: u16 = 10; // columns reserved for axis labels on the right
const chart_cols = width -| 2 -| label_cols; // 1 col left margin + label area on right
if (chart_cols == 0) return;

View file

@ -113,6 +113,7 @@
const std = @import("std");
const vaxis = @import("vaxis");
const StyledLine = @import("../tui.zig").StyledLine;
/// Re-exported KeyCombo so tab modules don't need to import
/// keybinds.zig directly for binding declarations. This is the
@ -402,6 +403,53 @@ pub fn validateTabModule(comptime Module: type) void {
);
}
// Draw hooks (mutually exclusive, exactly one required)
//
// Every tab declares EXACTLY ONE of:
//
// pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine
// pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void
//
// `buildStyledLines` is for line-list tabs (App handles
// scroll_offset clamping + cell rendering). `drawContent`
// is for direct-buffer tabs (cell layout / Kitty
// graphics that don't fit the line-list shape).
//
// The framework's draw dispatcher tries `drawContent`
// first, then falls back to `buildStyledLines`. Declaring
// neither leaves the tab unrenderable; declaring both is
// ambiguous. The validator surfaces both as compile errors.
const has_build = @hasDecl(Module, "buildStyledLines");
const has_draw = @hasDecl(Module, "drawContent");
if (!has_build and !has_draw) {
@compileError("Tab module `" ++ mod_name ++ "` must declare exactly one of " ++
"`buildStyledLines` or `drawContent`. See the framework draw-hook docs in tab_framework.zig.");
}
if (has_build and has_draw) {
@compileError("Tab module `" ++ mod_name ++ "` declares both `buildStyledLines` and " ++
"`drawContent`. Only one is allowed — pick the right one for your tab's render shape.");
}
if (has_build) {
expectFnInferredError(
mod_name,
Module,
"buildStyledLines",
&.{ *State, *App, std.mem.Allocator },
[]const StyledLine,
"pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine { ... }",
);
}
if (has_draw) {
expectFnInferredError(
mod_name,
Module,
"drawContent",
&.{ *State, *App, std.mem.Allocator, []vaxis.Cell, u16, u16 },
void,
"pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { ... }",
);
}
// Context-change hooks (optional, typed when present)
if (@hasDecl(tab_decl, "onSymbolChange")) {
expectFn(