complete tui.zig architectural refactor
This commit is contained in:
parent
e301757311
commit
3ff42591ad
10 changed files with 624 additions and 505 deletions
568
src/tui.zig
568
src/tui.zig
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue