diff --git a/src/tui.zig b/src/tui.zig index 706be8a..08acdb5 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -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(), .{}); diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index d687f68..a2fd623 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -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) { diff --git a/src/tui/projections_tab.zig b/src/tui/projections_tab.zig index bb42522..4bf9277 100644 --- a/src/tui/projections_tab.zig +++ b/src/tui/projections_tab.zig @@ -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