migrate projections tab to new framework

This commit is contained in:
Emil Lerch 2026-05-14 22:43:28 -07:00
parent 61dd86dc9a
commit d4d09f73d2
Signed by: lobo
GPG key ID: A7B62D657EF764F8
3 changed files with 311 additions and 186 deletions

View file

@ -288,6 +288,7 @@ pub const TabStates = struct {
performance: performance_tab.State = .{},
options: options_tab.State = .{},
history: history_tab.State = .{},
projections: projections_tab.State = .{},
};
/// Comptime registry of all tab modules conforming to the
@ -325,6 +326,7 @@ const tab_modules = .{
.performance = performance_tab,
.options = options_tab,
.history = history_tab,
.projections = projections_tab,
};
comptime {
@ -576,38 +578,7 @@ pub const App = struct {
account_search_cursor: usize = 0, // cursor within search_matches
// History tab state lives in `self.states.history` (see TabStates).
// Projections tab state
projections_loaded: bool = false,
projections_disabled: bool = false,
projections_config: @import("analytics/projections.zig").UserConfig = .{},
projections_ctx: ?@import("views/projections.zig").ProjectionContext = null,
projections_horizon_idx: usize = 0,
projections_image_id: ?u32 = null, // Kitty graphics image ID for projection chart
projections_image_width: u16 = 0,
projections_image_height: u16 = 0,
projections_chart_dirty: bool = true,
projections_chart_visible: bool = true,
projections_events_enabled: bool = true,
projections_value_min: f64 = 0,
projections_value_max: f64 = 0,
/// When non-null, the projections tab renders against a historical
/// snapshot instead of the live portfolio. Set via the `d` popup
/// (parsed by `cli.parseAsOfDate`) and auto-snapped to the nearest
/// earlier available snapshot. Cleared by `D` or by committing
/// an empty / "live" input.
projections_as_of: ?zfin.Date = null,
/// When auto-snap kicked in, `projections_as_of` is the resolved
/// snapshot date but `projections_as_of_requested` remembers what
/// the user actually typed surfaced in the tab header as a muted
/// "(requested X; snapped to Y, N days earlier)" note.
projections_as_of_requested: ?zfin.Date = null,
/// When true, the projections chart overlays the realized
/// portfolio trajectory (snapshots + imported_values) on top of
/// the percentile bands. Toggled by the `o` keybind. Only
/// meaningful when `projections_as_of` is set; the keybind
/// flashes a status message and leaves this off otherwise.
projections_overlay_actuals: bool = false,
// Projections tab state lives in `self.states.projections`.
// Mouse wheel debounce for cursor-based tabs (portfolio, options).
// Terminals often send multiple wheel events per physical tick.
@ -1029,21 +1000,19 @@ pub const App = struct {
self.input_len = 0;
return ctx.consumeAndRedraw();
}
self.projections_as_of = d;
self.projections_as_of_requested = null;
self.states.projections.as_of = d;
self.states.projections.as_of_requested = null;
var status_buf: [64]u8 = undefined;
const msg = std.fmt.bufPrint(&status_buf, "As-of: {f}", .{d}) catch "As-of set";
self.setStatus(msg);
} else {
// `null` parse result = live.
self.projections_as_of = null;
self.projections_as_of_requested = null;
self.states.projections.as_of = null;
self.states.projections.as_of_requested = null;
self.setStatus("As-of cleared — showing live");
}
projections_tab.freeLoaded(self);
self.projections_loaded = false;
projections_tab.loadData(self);
projections_tab.tab.reload(&self.states.projections, self) catch {};
self.mode = .normal;
self.input_len = 0;
@ -1302,13 +1271,11 @@ pub const App = struct {
self.setStatus("Filter cleared: showing all accounts");
return ctx.consumeAndRedraw();
}
if (self.active_tab == .projections and self.projections_as_of != null) {
self.projections_as_of = null;
self.projections_as_of_requested = null;
self.projections_overlay_actuals = false;
projections_tab.freeLoaded(self);
self.projections_loaded = false;
projections_tab.loadData(self);
if (self.active_tab == .projections and self.states.projections.as_of != null) {
self.states.projections.as_of = null;
self.states.projections.as_of_requested = null;
self.states.projections.overlay_actuals = false;
projections_tab.tab.reload(&self.states.projections, self) catch {};
self.setStatus("As-of cleared — showing live");
return ctx.consumeAndRedraw();
}
@ -1516,24 +1483,7 @@ pub const App = struct {
return ctx.consumeAndRedraw();
}
if (self.active_tab == .projections) {
if (self.projections_as_of == null) {
self.setStatus("Overlay only available with --as-of (press d to set)");
return ctx.consumeAndRedraw();
}
self.projections_overlay_actuals = !self.projections_overlay_actuals;
// Re-run loadData so the overlay section gets
// built (or freed). The timeline load is the
// expensive bit but it's rare humans toggle
// this maybe a few times per session.
projections_tab.freeLoaded(self);
self.projections_loaded = false;
projections_tab.loadData(self);
self.projections_chart_dirty = true;
if (self.projections_overlay_actuals) {
self.setStatus("Overlay: ON — tracks trajectory, not SWR validity");
} else {
self.setStatus("Overlay: OFF");
}
projections_tab.tab.handleAction(&self.states.projections, self, projections_tab.Action.overlay_actuals);
return ctx.consumeAndRedraw();
}
},
@ -1555,20 +1505,13 @@ pub const App = struct {
},
.toggle_chart => {
if (self.active_tab == .projections) {
self.projections_chart_visible = !self.projections_chart_visible;
self.projections_chart_dirty = true;
self.scroll_offset = 0;
projections_tab.tab.handleAction(&self.states.projections, self, projections_tab.Action.toggle_chart);
return ctx.consumeAndRedraw();
}
},
.toggle_events => {
if (self.active_tab == .projections) {
self.projections_events_enabled = !self.projections_events_enabled;
projections_tab.freeLoaded(self);
self.projections_loaded = false;
projections_tab.loadData(self);
const label = if (self.projections_events_enabled) "Events enabled" else "Events disabled";
self.setStatus(label);
projections_tab.tab.handleAction(&self.states.projections, self, projections_tab.Action.toggle_events);
return ctx.consumeAndRedraw();
}
},
@ -1577,10 +1520,7 @@ pub const App = struct {
// let the same key flow to their own handlers (none
// currently bind plain 'd').
if (self.active_tab == .projections) {
self.mode = .date_input;
self.input_len = 0;
// No setStatus drawStatusBar replaces the whole
// line with the prompt + hint when mode is .date_input.
projections_tab.tab.handleAction(&self.states.projections, self, projections_tab.Action.as_of_input);
return ctx.consumeAndRedraw();
}
},
@ -1781,8 +1721,7 @@ pub const App = struct {
history_tab.tab.reload(&self.states.history, self) catch {};
},
.projections => {
self.projections_loaded = false;
projections_tab.freeLoaded(self);
projections_tab.tab.reload(&self.states.projections, self) catch {};
},
}
self.loadTabData();
@ -1828,8 +1767,7 @@ pub const App = struct {
history_tab.tab.activate(&self.states.history, self) catch {};
},
.projections => {
if (self.projections_disabled) return;
if (!self.projections_loaded) projections_tab.loadData(self);
projections_tab.tab.activate(&self.states.projections, self) catch {};
},
}
}
@ -1884,7 +1822,7 @@ pub const App = struct {
analysis_tab.tab.deinit(&self.states.analysis, self);
self.portfolio.deinit(self.allocator);
history_tab.tab.deinit(&self.states.history, self);
projections_tab.freeLoaded(self);
projections_tab.tab.deinit(&self.states.projections, self);
quote_tab.tab.deinit(&self.states.quote, self);
}
@ -1961,15 +1899,12 @@ pub const App = struct {
}
/// Whether the given tab should be treated as disabled in
/// the current App context. Migrated tabs are consulted via
/// their framework-contract `isDisabled` hook; unmigrated tabs
/// (projections) still use bespoke `_disabled` flags set during
/// App init. The unmigrated branch goes away when projections
/// adopts the framework the `_disabled` field will be deleted
/// alongside the inline check here.
/// the current App context. All migrated tabs are consulted
/// via their framework-contract `isDisabled` hook. (Portfolio
/// is the only remaining unmigrated tab; it has no disabled
/// predicate today.)
fn isDisabled(self: *App, t: Tab) bool {
return self.appPredicate(t, "isDisabled") or
(t == .projections and self.projections_disabled);
return self.appPredicate(t, "isDisabled");
}
fn isSymbolSelected(self: *App) bool {
@ -2016,7 +1951,7 @@ pub const App = struct {
const start = @min(self.scroll_offset, if (lines.len > 0) lines.len - 1 else 0);
try self.drawStyledContent(ctx.arena, buf, width, height, lines[start..]);
},
.projections => try projections_tab.drawContent(self, ctx, buf, width, height),
.projections => try projections_tab.drawContent(&self.states.projections, self, ctx, buf, width, height),
}
}
@ -2539,15 +2474,6 @@ pub fn run(
app_inst.active_tab = .quote;
}
// Disable projections tab when no portfolio is loaded.
// Analysis derives the same condition via its `isDisabled`
// method (no field write needed) the predicate is computed
// from `app.portfolio.file == null` directly. History does the
// same see `history_tab.tab.isDisabled`.
if (app_inst.portfolio.file == null) {
app_inst.projections_disabled = true;
}
// Pre-fetch portfolio prices before TUI starts, with stderr progress.
// This runs while the terminal is still in normal mode so output is visible.
if (app_inst.portfolio.file) |pf| {
@ -2621,9 +2547,9 @@ pub fn run(
vx_app.vx.freeImage(vx_app.tty.writer(), id);
app_inst.states.quote.chart.image_id = null;
}
if (app_inst.projections_image_id) |id| {
if (app_inst.states.projections.image_id) |id| {
vx_app.vx.freeImage(vx_app.tty.writer(), id);
app_inst.projections_image_id = null;
app_inst.states.projections.image_id = null;
}
}
try vx_app.run(app_inst.widget(), .{});

View file

@ -1297,12 +1297,15 @@ pub fn reloadPortfolioFile(app: *App) void {
analysis_tab.tab.activate(&app.states.analysis, app) catch {};
}
// Invalidate projections data projections.srf may have changed
projections_tab.freeLoaded(app);
app.projections_loaded = false;
app.projections_disabled = false;
// Invalidate projections data projections.srf may have changed.
// Always drop the cached context so a stale render doesn't leak;
// re-fetch only if the user is actively looking at projections.
// (When not active, the next `activate` lazily re-fetches.)
if (app.active_tab == .projections) {
projections_tab.loadData(app);
projections_tab.tab.reload(&app.states.projections, app) catch {};
} else {
projections_tab.freeLoaded(&app.states.projections, app);
app.states.projections.loaded = false;
}
if (missing > 0) {

View file

@ -13,7 +13,7 @@
//!
//! ## As-of mode
//!
//! When `app.projections_as_of` is non-null, the tab renders against a
//! When `state.as_of` is non-null, the tab renders against a
//! historical snapshot instead of the live portfolio, using
//! `view.loadProjectionContextAsOf`. The user toggles this via the `d`
//! keybind (date popup) or `D` (return to live). Auto-snaps to the
@ -34,14 +34,210 @@ const performance = @import("../analytics/performance.zig");
const valuation = @import("../analytics/valuation.zig");
const view = @import("../views/projections.zig");
const history = @import("../history.zig");
const framework = @import("tab_framework.zig");
const App = tui.App;
const StyledLine = tui.StyledLine;
// Tab-local action enum
//
// Projections tab keybinds (today routed through the legacy
// global `keybinds.Action` variants and the central tui.zig
// switch `o` sort_reverse, `c` toggle_chart, `b`
// toggle_events, `d` projections_as_of_input. When scoped
// keymaps land (TODO step 3), these become genuinely tab-local
// and the global enum variants for them disappear.
pub const Action = enum {
/// Toggle the as-of-anchored actuals overlay on the projection
/// chart. No-op (with status hint) when not in as-of mode.
/// Today bound to `o` via the global `sort_reverse` action.
overlay_actuals,
/// Show / hide the percentile-band chart. Toggles
/// `state.chart_visible`; doesn't reload data.
toggle_chart,
/// Enable / disable simulated lifecycle events (RMDs, lump-sum
/// withdrawals). Forces a reload because the simulation engine
/// re-runs with the new flag.
toggle_events,
/// Open the as-of date input mini-popup. Mode transition is
/// handled by the App; the popup commit / clear path lives in
/// tui.zig and calls `loadData` on this tab.
as_of_input,
};
// Tab-private state
pub const State = struct {
/// Whether `activate` has populated `ctx` (or set up
/// disabled state). Distinct from `ctx != null` because failed
/// loads still mark loaded.
loaded: bool = false,
/// User-tunable inputs to the projection engine (annual
/// contribution, target spending, retirement target percentile,
/// etc.). Driven by annotations on the portfolio file.
config: @import("../analytics/projections.zig").UserConfig = .{},
/// Loaded projection context: bands, withdrawal tables,
/// horizon configs, optional overlay actuals. Owned by State;
/// freed via `freeLoaded`.
ctx: ?@import("../views/projections.zig").ProjectionContext = null,
/// Currently-focused horizon row in the terminal-value table.
/// (Reserved for future expansion; not consumed today.)
horizon_idx: usize = 0,
/// Kitty graphics image id for the percentile-band chart, when
/// using the Kitty path. Null when no image is currently
/// transmitted to the terminal.
image_id: ?u32 = null,
/// Pixel dimensions of the most-recently-transmitted image
/// (used to detect resize and re-render).
image_width: u16 = 0,
image_height: u16 = 0,
/// True when the chart needs re-rendering on the next draw.
/// Set by `freeLoaded`, by toggle_chart, by overlay_actuals
/// changes, etc.
chart_dirty: bool = true,
/// Whether the percentile-band chart is shown. Toggled by
/// `toggle_chart`. When false, the tab renders text-only with
/// scroll (the full report).
chart_visible: bool = true,
/// Whether simulated lifecycle events (RMDs, lump-sum
/// withdrawals) are included in the projection. Toggled by
/// `toggle_events`; flipping forces a reload.
events_enabled: bool = true,
/// Y-axis bounds last used for the chart informational, not
/// load-bearing in dispatch.
value_min: f64 = 0,
value_max: f64 = 0,
/// When non-null, the projections tab renders against a historical
/// snapshot instead of the live portfolio. Set via the `d` popup
/// (parsed by `cli.parseAsOfDate`) and auto-snapped to the nearest
/// earlier available snapshot. Cleared by Esc on the projections
/// tab when set, or by committing an empty / "live" input.
as_of: ?zfin.Date = null,
/// When auto-snap kicked in, `as_of` is the resolved snapshot
/// date but `as_of_requested` remembers what the user actually
/// typed surfaced in the tab header as a muted "(requested X;
/// snapped to Y, N days earlier)" note.
as_of_requested: ?zfin.Date = null,
/// When true, the projections chart overlays the realized
/// portfolio trajectory (snapshots + imported_values) on top of
/// the percentile bands. Toggled by `overlay_actuals`. Only
/// meaningful when `as_of` is set; the action flashes a status
/// message and leaves this off otherwise.
overlay_actuals: bool = false,
};
// Tab framework contract
pub const tab = struct {
pub const ActionT = Action;
pub const StateT = State;
pub const default_bindings: []const framework.TabBinding(Action) = &.{
// Today's keybinds are in the global keymap (sort_reverse,
// toggle_chart, toggle_events, projections_as_of_input).
// These per-tab declarations become authoritative when
// scoped keymaps land.
.{ .action = .overlay_actuals, .key = .{ .codepoint = 'o' } },
.{ .action = .toggle_chart, .key = .{ .codepoint = 'c' } },
.{ .action = .toggle_events, .key = .{ .codepoint = 'b' } },
.{ .action = .as_of_input, .key = .{ .codepoint = 'd' } },
};
pub const action_labels = std.enums.EnumArray(Action, []const u8).init(.{
.overlay_actuals = "Toggle actuals overlay",
.toggle_chart = "Toggle chart visibility",
.toggle_events = "Toggle lifecycle events",
.as_of_input = "Set as-of date",
});
pub const status_hints: []const Action = &.{
.toggle_chart,
.toggle_events,
.as_of_input,
};
pub fn init(state: *State, app: *App) !void {
_ = app;
state.* = .{};
}
pub fn deinit(state: *State, app: *App) void {
freeLoaded(state, app);
state.* = .{};
}
pub fn activate(state: *State, app: *App) !void {
if (state.loaded) return;
loadData(state, app);
}
pub const deactivate = framework.noopDeactivate(State);
pub fn reload(state: *State, app: *App) !void {
state.loaded = false;
freeLoaded(state, app);
loadData(state, app);
}
pub const tick = framework.noopTick(State);
pub fn handleAction(state: *State, app: *App, action: Action) void {
switch (action) {
.overlay_actuals => {
if (state.as_of == null) {
app.setStatus("Overlay only available with --as-of (press d to set)");
return;
}
state.overlay_actuals = !state.overlay_actuals;
// Re-run loadData so the overlay section gets built
// (or freed). The timeline load is the expensive
// bit but it's rare humans toggle this maybe a
// few times per session.
freeLoaded(state, app);
state.loaded = false;
loadData(state, app);
state.chart_dirty = true;
if (state.overlay_actuals) {
app.setStatus("Overlay: ON — tracks trajectory, not SWR validity");
} else {
app.setStatus("Overlay: OFF");
}
},
.toggle_chart => {
state.chart_visible = !state.chart_visible;
state.chart_dirty = true;
app.scroll_offset = 0;
},
.toggle_events => {
state.events_enabled = !state.events_enabled;
freeLoaded(state, app);
state.loaded = false;
loadData(state, app);
const label = if (state.events_enabled) "Events enabled" else "Events disabled";
app.setStatus(label);
},
.as_of_input => {
app.mode = .date_input;
app.input_len = 0;
// No setStatus drawStatusBar replaces the whole
// line with the prompt + hint when mode is .date_input.
},
}
}
/// Projections requires a loaded portfolio (the simulation
/// engine reads lots / allocations from `app.portfolio`). Same
/// predicate as analysis_tab and history_tab.
pub fn isDisabled(app: *App) bool {
return app.portfolio.file == null;
}
};
// Data loading
pub fn loadData(app: *App) void {
app.projections_loaded = true;
freeLoaded(app);
pub fn loadData(state: *State, app: *App) void {
state.loaded = true;
freeLoaded(state, app);
const portfolio_path = app.portfolio_path orelse {
app.setStatus("Projections tab requires a loaded portfolio");
@ -61,25 +257,25 @@ pub fn loadData(app: *App) void {
// message explaining why, and fall through to the live path so
// the tab still shows something rather than going blank.
as_of: {
const requested_date = app.projections_as_of orelse break :as_of;
const requested_date = state.as_of orelse break :as_of;
const resolution = resolveAsOf(app, portfolio_path, requested_date) orelse {
const resolution = resolveAsOf(state, app, portfolio_path, requested_date) orelse {
// `setStatus` already called by resolveAsOf.
app.projections_as_of = null;
app.projections_as_of_requested = null;
state.as_of = null;
state.as_of_requested = null;
break :as_of;
};
const actual_date = resolution.actual;
app.projections_as_of = actual_date;
state.as_of = actual_date;
// Preserve requested for the header note; clear if it matches actual.
if (actual_date.eql(requested_date)) {
app.projections_as_of_requested = null;
state.as_of_requested = null;
}
const hist_dir = history.deriveHistoryDir(app.allocator, portfolio_path) catch {
app.setStatus("Failed to derive history dir — showing live");
app.projections_as_of = null;
app.projections_as_of_requested = null;
state.as_of = null;
state.as_of_requested = null;
break :as_of;
};
defer app.allocator.free(hist_dir);
@ -88,8 +284,8 @@ pub fn loadData(app: *App) void {
.snapshot => snap: {
var loaded = history.loadSnapshotAt(app.io, app.allocator, hist_dir, actual_date) catch {
app.setStatus("Failed to load snapshot — showing live");
app.projections_as_of = null;
app.projections_as_of_requested = null;
state.as_of = null;
state.as_of_requested = null;
break :as_of;
};
defer loaded.deinit(app.allocator);
@ -101,11 +297,11 @@ pub fn loadData(app: *App) void {
&loaded.snap,
actual_date,
app.svc,
app.projections_events_enabled,
state.events_enabled,
) catch {
app.setStatus("Failed to compute as-of projections — showing live");
app.projections_as_of = null;
app.projections_as_of_requested = null;
state.as_of = null;
state.as_of_requested = null;
break :as_of;
};
},
@ -116,14 +312,14 @@ pub fn loadData(app: *App) void {
// up-front into `app.portfolio.summary`.
const summary = app.portfolio.summary orelse {
app.setStatus("Imported as-of needs live portfolio — visit Portfolio tab first");
app.projections_as_of = null;
app.projections_as_of_requested = null;
state.as_of = null;
state.as_of_requested = null;
break :as_of;
};
const portfolio = app.portfolio.file orelse {
app.setStatus("Imported as-of needs live portfolio — visit Portfolio tab first");
app.projections_as_of = null;
app.projections_as_of_requested = null;
state.as_of = null;
state.as_of_requested = null;
break :as_of;
};
@ -138,11 +334,11 @@ pub fn loadData(app: *App) void {
resolution.liquid,
actual_date,
app.svc,
app.projections_events_enabled,
state.events_enabled,
) catch {
app.setStatus("Failed to compute as-of projections — showing live");
app.projections_as_of = null;
app.projections_as_of_requested = null;
state.as_of = null;
state.as_of_requested = null;
break :as_of;
};
},
@ -152,7 +348,7 @@ pub fn loadData(app: *App) void {
// Attach the actuals overlay if the toggle is on. Failures
// here are non-fatal the chart still renders without the
// overlay; the toggle stays on so the user knows the intent.
if (app.projections_overlay_actuals) {
if (state.overlay_actuals) {
if (loadOverlayActuals(app, portfolio_path, actual_date)) |ov| {
ctx_with_overlay.overlay_actuals = ov;
} else |_| {
@ -162,7 +358,7 @@ pub fn loadData(app: *App) void {
}
}
app.projections_ctx = ctx_with_overlay;
state.ctx = ctx_with_overlay;
return;
}
@ -184,14 +380,14 @@ pub fn loadData(app: *App) void {
portfolio.totalCash(app.today),
portfolio.totalCdFaceValue(app.today),
app.svc,
app.projections_events_enabled,
state.events_enabled,
app.today,
) catch {
app.setStatus("Failed to compute projections");
return;
};
app.projections_ctx = ctx;
state.ctx = ctx;
}
/// Resolve the user's requested as-of date against the portfolio's
@ -202,7 +398,7 @@ pub fn loadData(app: *App) void {
/// Thin adapter over `history.resolveAsOfDate` the shared pure
/// resolver owns exact-then-fallback logic; this wrapper maps its
/// errors to user-visible status-bar messages and handles the arena.
fn resolveAsOf(app: *App, portfolio_path: []const u8, requested: zfin.Date) ?history.ResolvedAsOf {
fn resolveAsOf(state: *State, app: *App, portfolio_path: []const u8, requested: zfin.Date) ?history.ResolvedAsOf {
var arena_state = std.heap.ArenaAllocator.init(app.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
@ -231,7 +427,7 @@ fn resolveAsOf(app: *App, portfolio_path: []const u8, requested: zfin.Date) ?his
if (!resolved.exact) {
// Remember the original request for the muted header note.
app.projections_as_of_requested = requested;
state.as_of_requested = requested;
}
return resolved;
}
@ -246,8 +442,8 @@ fn loadOverlayActuals(app: *App, portfolio_path: []const u8, as_of: zfin.Date) !
return try view.buildOverlayActuals(app.allocator, loaded.series.points, as_of, app.today);
}
pub fn freeLoaded(app: *App) void {
if (app.projections_ctx) |*ctx| {
pub fn freeLoaded(state: *State, app: *App) void {
if (state.ctx) |*ctx| {
app.allocator.free(ctx.data.withdrawals);
for (ctx.data.bands) |b| {
if (b) |slice| app.allocator.free(slice);
@ -256,14 +452,14 @@ pub fn freeLoaded(app: *App) void {
if (ctx.earliest) |er| app.allocator.free(er);
if (ctx.overlay_actuals) |*ov| ov.deinit();
}
app.projections_ctx = null;
state.ctx = null;
// Mark projection chart as dirty so it re-renders on next draw
app.projections_chart_dirty = true;
state.chart_dirty = true;
}
// Rendering
pub fn drawContent(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void {
pub fn drawContent(state: *State, app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void {
const arena = ctx.arena;
// Determine whether to use Kitty graphics
@ -274,7 +470,7 @@ pub fn drawContent(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, wi
};
// Need bands data for the chart
const has_bands = if (app.projections_ctx) |pctx| blk: {
const has_bands = if (state.ctx) |pctx| blk: {
const horizons = pctx.config.getHorizons();
if (horizons.len == 0) break :blk false;
const last_idx = horizons.len - 1;
@ -284,27 +480,27 @@ pub fn drawContent(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, wi
break :blk false;
} else false;
if (use_kitty and has_bands and app.projections_chart_visible) {
drawWithKittyChart(app, ctx, buf, width, height) catch {
try drawWithScroll(app, arena, buf, width, height);
if (use_kitty and has_bands and state.chart_visible) {
drawWithKittyChart(state, app, ctx, buf, width, height) catch {
try drawWithScroll(state, app, arena, buf, width, height);
};
} else {
try drawWithScroll(app, arena, buf, width, height);
try drawWithScroll(state, app, arena, buf, width, height);
}
}
/// Render styled lines with scroll_offset applied.
fn drawWithScroll(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
const all_lines = try buildStyledLines(app, arena);
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 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(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void {
fn drawWithKittyChart(state: *State, app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void {
const arena = ctx.arena;
const th = app.theme;
const pctx = app.projections_ctx orelse return;
const pctx = state.ctx orelse return;
const config = pctx.config;
const horizons = config.getHorizons();
const last_idx = horizons.len - 1;
@ -313,7 +509,7 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
// Build text header (benchmark comparison + allocation note)
var header_lines: std.ArrayListUnmanaged(StyledLine) = .empty;
try buildHeaderSection(app, arena, &header_lines, pctx);
try buildHeaderSection(state, app, arena, &header_lines, pctx);
// Chart title
try header_lines.append(arena, .{ .text = "", .style = th.contentStyle() });
@ -338,7 +534,7 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
const chart_rows = height -| header_rows -| footer_reserve;
if (chart_rows < 6) {
// Not enough space for chart fall back to text-only with scroll
try drawWithScroll(app, arena, buf, width, height);
try drawWithScroll(state, app, arena, buf, width, height);
return;
}
@ -356,13 +552,13 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
const capped_h = @min(px_h, app.chart_config.max_height);
// Render or reuse cached image
if (app.projections_chart_dirty) {
if (state.chart_dirty) {
// Free old image
if (app.projections_image_id) |old_id| {
if (state.image_id) |old_id| {
if (app.vx_app) |va| {
va.vx.freeImage(va.tty.writer(), old_id);
}
app.projections_image_id = null;
state.image_id = null;
}
if (app.vx_app) |va| {
@ -377,8 +573,8 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
// dirty redraw because the overlay is at most ~12 years
// of weekly data (~600 points).
const overlay_input: ?projection_chart.ActualsOverlay = blk: {
if (!app.projections_overlay_actuals) break :blk null;
const ctx_data = app.projections_ctx orelse break :blk null;
if (!state.overlay_actuals) break :blk null;
const ctx_data = state.ctx orelse break :blk null;
const ov = ctx_data.overlay_actuals orelse break :blk null;
const ov_buf = app.allocator.alloc(projection_chart.ActualsPoint, ov.points.len) catch break :blk null;
for (ov.points, 0..) |p, idx| {
@ -397,7 +593,7 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
th,
overlay_input,
) catch {
app.projections_chart_dirty = false;
state.chart_dirty = false;
return;
};
defer app.allocator.free(chart_result.rgb_data);
@ -405,7 +601,7 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
// Base64-encode and transmit
const base64_enc = std.base64.standard.Encoder;
const b64_buf = app.allocator.alloc(u8, base64_enc.calcSize(chart_result.rgb_data.len)) catch {
app.projections_chart_dirty = false;
state.chart_dirty = false;
return;
};
defer app.allocator.free(b64_buf);
@ -418,21 +614,21 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
chart_result.height,
.rgb,
) catch {
app.projections_chart_dirty = false;
state.chart_dirty = false;
return;
};
app.projections_image_id = img.id;
app.projections_image_width = @intCast(chart_cols);
app.projections_image_height = chart_rows;
app.projections_value_min = chart_result.value_min;
app.projections_value_max = chart_result.value_max;
app.projections_chart_dirty = false;
state.image_id = img.id;
state.image_width = @intCast(chart_cols);
state.image_height = chart_rows;
state.value_min = chart_result.value_min;
state.value_max = chart_result.value_max;
state.chart_dirty = false;
}
}
// Place the image in the cell buffer
if (app.projections_image_id) |img_id| {
if (state.image_id) |img_id| {
const chart_row_start: usize = header_rows;
const chart_col_start: usize = 1;
const buf_idx = chart_row_start * @as(usize, width) + chart_col_start;
@ -444,8 +640,8 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
.img_id = img_id,
.options = .{
.size = .{
.rows = app.projections_image_height,
.cols = app.projections_image_width,
.rows = state.image_height,
.cols = state.image_width,
},
.scale = .contain,
},
@ -454,23 +650,23 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
}
// Axis labels (dollar values on the right side)
const img_rows = app.projections_image_height;
const label_col: usize = @as(usize, chart_col_start) + @as(usize, app.projections_image_width) + 1;
const img_rows = state.image_height;
const label_col: usize = @as(usize, chart_col_start) + @as(usize, state.image_width) + 1;
const label_style = th.mutedStyle();
if (label_col + 10 <= width and img_rows >= 4 and app.projections_value_max > app.projections_value_min) {
if (label_col + 10 <= width and img_rows >= 4 and state.value_max > state.value_min) {
// Label band boundaries at the right edge, in priority order:
// p10 and p90 (extremes, always kept), then p50 (median), then p25/p75 (lowest priority).
const last_band = bands[bands.len - 1];
const label_values = [_]f64{ last_band.p10, last_band.p90, last_band.p50, last_band.p25, last_band.p75 };
const val_range = app.projections_value_max - app.projections_value_min;
const val_range = state.value_max - state.value_min;
const rows_f = @as(f64, @floatFromInt(img_rows -| 1));
var placed_rows: [5]usize = undefined;
var placed_count: usize = 0;
for (label_values) |val| {
const norm = (val - app.projections_value_min) / val_range;
const norm = (val - state.value_min) / val_range;
const row_f = @as(f64, @floatFromInt(chart_row_start)) + (1.0 - norm) * rows_f;
const row: usize = @intFromFloat(@round(row_f));
if (row >= height) continue;
@ -536,7 +732,7 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
}
// Render footer (terminal values + withdrawal table) below the chart
const footer_start_row = header_rows + app.projections_image_height + 1; // +1 for axis row
const footer_start_row = header_rows + state.image_height + 1; // +1 for axis row
if (footer_start_row + 4 < height) {
const footer_slice = try footer_lines.toOwnedSlice(arena);
const footer_buf_start = footer_start_row * @as(usize, width);
@ -549,7 +745,7 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
}
/// Build the header section (benchmark comparison table + allocation note).
fn buildHeaderSection(app: *App, arena: std.mem.Allocator, lines: *std.ArrayListUnmanaged(StyledLine), pctx: view.ProjectionContext) !void {
fn buildHeaderSection(state: *State, app: *App, arena: std.mem.Allocator, lines: *std.ArrayListUnmanaged(StyledLine), pctx: view.ProjectionContext) !void {
const th = app.theme;
const comparison = pctx.comparison;
const config = pctx.config;
@ -624,7 +820,7 @@ fn buildHeaderSection(app: *App, arena: std.mem.Allocator, lines: *std.ArrayList
// historical snapshot date when one is configured so the
// promoted date and earliest-retirement grid anchor on the
// same reference point as the rest of the as-of-mode display.
const ref_date = app.projections_as_of orelse app.today;
const ref_date = state.as_of orelse app.today;
try appendAccumulationBlocks(lines, arena, th, pctx, ref_date);
}
@ -884,13 +1080,13 @@ fn appendAccumulationBlocks(
}
}
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine {
const th = app.theme;
var lines: std.ArrayListUnmanaged(StyledLine) = .empty;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
const ctx = app.projections_ctx orelse {
const ctx = state.ctx orelse {
try lines.append(arena, .{ .text = " No projection data. Ensure portfolio is loaded.", .style = th.mutedStyle() });
return lines.toOwnedSlice(arena);
};
@ -903,8 +1099,8 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine
// historical snapshot. Muted header note so it doesn't compete
// with the main content. If the user asked for a date that had no
// exact snapshot, a second muted line explains the auto-snap.
if (app.projections_as_of) |actual| {
const source_label: []const u8 = if (app.projections_ctx) |c| switch (c.as_of_source) {
if (state.as_of) |actual| {
const source_label: []const u8 = if (state.ctx) |c| switch (c.as_of_source) {
.snapshot => "snapshot",
.imported => "imported",
.live => "live",
@ -912,7 +1108,7 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine
const header = try std.fmt.allocPrint(arena, " As-of: {f} ({s})", .{ actual, source_label });
try lines.append(arena, .{ .text = header, .style = th.mutedStyle() });
if (app.projections_as_of_requested) |requested| {
if (state.as_of_requested) |requested| {
if (!requested.eql(actual)) {
const diff = requested.days - actual.days;
const note = try std.fmt.allocPrint(
@ -923,7 +1119,7 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine
try lines.append(arena, .{ .text = note, .style = th.mutedStyle() });
}
}
if (app.projections_ctx) |c| {
if (state.ctx) |c| {
if (c.as_of_source == .imported) {
try lines.append(arena, .{
.text = " (bands use today's allocation scaled to the imported liquid total)",
@ -931,7 +1127,7 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine
});
}
}
if (app.projections_overlay_actuals) {
if (state.overlay_actuals) {
const ov_note = try std.fmt.allocPrint(
arena,
" Overlay: actuals from {f} · tracks trajectory, not SWR validity",
@ -1014,7 +1210,7 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine
// historical snapshot date when one is configured so the
// promoted date and earliest-retirement grid anchor on the
// same reference point as the rest of the as-of-mode display.
const ref_date = app.projections_as_of orelse app.today;
const ref_date = state.as_of orelse app.today;
try appendAccumulationBlocks(&lines, arena, th, ctx, ref_date);
// Braille chart: median portfolio value over the longest horizon