From 2bd49af8f316f57fa4567307d7cc5a0d33cd3e54 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Sat, 27 Jun 2026 07:57:43 -0700 Subject: [PATCH] clean remaining tui.zig "reach-ins" --- src/tui.zig | 89 +++++++++++++++++++++++++------------ src/tui/portfolio_tab.zig | 36 +++++++++++++++ src/tui/projections_tab.zig | 11 +++++ src/tui/quote_tab.zig | 11 +++++ src/tui/tab_framework.zig | 46 +++++++++++++++++++ 5 files changed, 165 insertions(+), 28 deletions(-) diff --git a/src/tui.zig b/src/tui.zig index eecb06f..e243e0e 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -1617,12 +1617,21 @@ pub const App = struct { return self.appPredicate(t, "isDisabled"); } + /// Whether the App's active symbol is the user-selected row in the + /// active tab - drives the `*` marker on the tab bar. Dispatches to + /// the active tab's optional `isSymbolSelected` hook (tabs without + /// it default to "not selected"); no App-level reach into any + /// specific tab's state. fn isSymbolSelected(self: *App) bool { - // Symbol is "selected" if it matches a portfolio/watchlist row the user explicitly selected with 's' - if (self.active_tab != .portfolio) return false; - if (self.states.portfolio.rows.items.len == 0) return false; - if (self.states.portfolio.cursor >= self.states.portfolio.rows.items.len) return false; - return std.mem.eql(u8, self.states.portfolio.rows.items[self.states.portfolio.cursor].symbol, self.symbol); + 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); + if (!@hasDecl(Module.tab, "isSymbolSelected")) return false; + const state_ptr = &@field(self.states, field.name); + return Module.tab.isSymbolSelected(state_ptr, self); + } + } + return false; } fn drawContent(self: *App, ctx: vaxis.vxfw.DrawContext, width: u16, height: u16) !vaxis.vxfw.Surface { @@ -1986,22 +1995,19 @@ pub const App = struct { return .{ .size = .{ .width = width, .height = 1 }, .widget = self.widget(), .buffer = buf, .children = &.{} }; } - // Default status bar: getStatus() + optional account-filter - // suffix on the portfolio tab. + // Default status bar: the App's status message, optionally + // annotated by the active tab's `statusSuffix` hook (e.g. + // portfolio's account filter). No App-level reach into any + // specific tab's state. const status_style = t.statusStyle(); @memset(buf, .{ .char = .{ .grapheme = " " }, .style = status_style }); - if (self.states.portfolio.account_filter != null and self.active_tab == .portfolio) { - const af = self.states.portfolio.account_filter.?; - const msg = self.getStatus(ctx.arena); - const filter_text = std.fmt.allocPrint(ctx.arena, "{s} [Account: {s}]", .{ msg, af }) catch msg; - for (0..@min(filter_text.len, width)) |i| { - buf[i] = .{ .char = .{ .grapheme = glyph(filter_text[i]) }, .style = status_style }; - } - } else { - const msg = self.getStatus(ctx.arena); - for (0..@min(msg.len, width)) |i| { - buf[i] = .{ .char = .{ .grapheme = glyph(msg[i]) }, .style = status_style }; - } + const msg = self.getStatus(ctx.arena); + const line = if (self.activeTabStatusSuffix(ctx.arena)) |suffix| + std.fmt.allocPrint(ctx.arena, "{s} {s}", .{ msg, suffix }) catch msg + else + msg; + for (0..@min(line.len, width)) |i| { + buf[i] = .{ .char = .{ .grapheme = glyph(line[i]) }, .style = status_style }; } return .{ .size = .{ .width = width, .height = 1 }, .widget = self.widget(), .buffer = buf, .children = &.{} }; @@ -2023,6 +2029,36 @@ pub const App = struct { return null; } + /// Call the active tab's `statusSuffix` hook (when declared) to get + /// an annotation appended to the default status message (e.g. + /// portfolio's account filter). Comptime-walks `tab_modules`; + /// returns null when the active tab declares no suffix. + fn activeTabStatusSuffix(self: *App, arena: std.mem.Allocator) ?[]const u8 { + 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); + if (!@hasDecl(Module.tab, "statusSuffix")) return null; + const state_ptr = &@field(self.states, field.name); + return Module.tab.statusSuffix(state_ptr, self, arena); + } + } + return null; + } + + /// Release every tab's transmitted Kitty graphics via the optional + /// `releaseGraphics` hook. Called once at App teardown (from the + /// run-scope defer) while `app.vx_app` is still valid - tabs without + /// graphics simply omit the hook. + fn releaseAllGraphics(self: *App) void { + inline for (std.meta.fields(@TypeOf(tab_modules))) |field| { + const Module = @field(tab_modules, field.name); + if (@hasDecl(Module.tab, "releaseGraphics")) { + const state_ptr = &@field(self.states, field.name); + Module.tab.releaseGraphics(state_ptr, self); + } + } + } + // ── Help ───────────────────────────────────────────────────── fn buildHelpStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { @@ -2630,15 +2666,12 @@ pub fn run( app_inst.vx_app = &vx_app; defer app_inst.vx_app = null; defer { - // Free any chart image before vaxis is torn down - if (app_inst.states.quote.chart.image_id) |id| { - vx_app.vx.freeImage(vx_app.tty.writer(), id); - app_inst.states.quote.chart.image_id = null; - } - if (app_inst.states.projections.image_id) |id| { - vx_app.vx.freeImage(vx_app.tty.writer(), id); - app_inst.states.projections.image_id = null; - } + // Free any per-tab Kitty chart images before vaxis is torn + // down. Each tab holding image IDs releases them via its + // optional `releaseGraphics` hook. This defer runs before + // the `vx_app = null` / `vx_app.deinit` defers above (LIFO), + // so `app.vx_app` is still valid inside the hooks. + app_inst.releaseAllGraphics(); } try vx_app.run(app_inst.widget(), .{}); } diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index f1d80b7..761e466 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -561,6 +561,30 @@ pub const tab = struct { }; } + /// Whether the App's active symbol is the row under the cursor - + /// drives the `*` marker the tab bar shows next to the symbol. + /// Active-tab hook; the App consults it generically rather than + /// reaching into portfolio state itself. + pub fn isSymbolSelected(state: *State, app: *App) bool { + if (state.cursor >= state.rows.items.len) return false; + return std.mem.eql(u8, state.rows.items[state.cursor].symbol, app.symbol); + } + + /// Status-bar suffix: when an account filter is active, annotate + /// the default status line with it (the App appends this to + /// `getStatus()`). Null when no filter is set. + pub fn statusSuffix(state: *State, app: *App, arena: std.mem.Allocator) ?[]const u8 { + _ = app; + return formatAccountSuffix(arena, state.account_filter); + } + + /// Format the account-filter status suffix (or null when unset). + /// Split from `statusSuffix` so it's unit-testable without an App. + fn formatAccountSuffix(arena: std.mem.Allocator, account_filter: ?[]const u8) ?[]const u8 { + const af = account_filter orelse return null; + return std.fmt.allocPrint(arena, "[Account: {s}]", .{af}) catch null; + } + /// Mouse handling. In account-picker mode, drives the modal /// (wheel scroll, click-to-select). Otherwise: clicks on the /// column-header row sort by that column; clicks on a data @@ -2489,6 +2513,18 @@ test "matchesAccountFilter: with filter, null account fails" { try testing.expect(!matchesAccountFilter(&state, null)); } +test "statusSuffix: formats the active account filter, null when unset" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const s = tab.formatAccountSuffix(arena, "Sample IRA"); + try testing.expect(s != null); + try testing.expectEqualStrings("[Account: Sample IRA]", s.?); + + try testing.expect(tab.formatAccountSuffix(arena, null) == null); +} + test "ensureCursorVisible: cursor above viewport scrolls up" { var state: State = .{ .cursor = 5, .header_lines = 2 }; var scroll: usize = 20; diff --git a/src/tui/projections_tab.zig b/src/tui/projections_tab.zig index 05cd2b7..5eec477 100644 --- a/src/tui/projections_tab.zig +++ b/src/tui/projections_tab.zig @@ -255,6 +255,17 @@ pub const tab = struct { state.* = .{}; } + /// Release the transmitted Kitty projection-chart image before + /// vaxis is torn down. Called across all tabs at App teardown while + /// `app.vx_app` is still valid (distinct from `deinit`, which runs + /// after vaxis is gone). + pub fn releaseGraphics(state: *State, app: *App) void { + if (state.image_id) |id| { + if (app.vx_app) |va| va.vx.freeImage(va.tty.writer(), id); + state.image_id = null; + } + } + pub fn activate(state: *State, app: *App) !void { if (state.loaded) return; // Projections reads `app.portfolio.summary` and `.file`, diff --git a/src/tui/quote_tab.zig b/src/tui/quote_tab.zig index 0176155..fc3a759 100644 --- a/src/tui/quote_tab.zig +++ b/src/tui/quote_tab.zig @@ -169,6 +169,17 @@ pub const tab = struct { state.* = .{}; } + /// Release the transmitted Kitty chart image before vaxis is torn + /// down. The App calls this across all tabs at teardown while + /// `app.vx_app` is still valid - distinct from `deinit`, which runs + /// after vaxis is already gone. + pub fn releaseGraphics(state: *State, app: *App) void { + if (state.chart.image_id) |id| { + if (app.vx_app) |va| va.vx.freeImage(va.tty.writer(), id); + state.chart.image_id = null; + } + } + /// On activation the Quote tab loads its shared candle data (by /// delegating to performance, which owns it) and fetches the live /// quote that drives the headline price/change - the same diff --git a/src/tui/tab_framework.zig b/src/tui/tab_framework.zig index f32975a..5139aa3 100644 --- a/src/tui/tab_framework.zig +++ b/src/tui/tab_framework.zig @@ -54,6 +54,22 @@ //! pub fn handlePaste(state: *State, app: *App, text: []const u8) bool { ... } //! pub fn statusOverride(state: *State, app: *App) ?framework.StatusOverride { ... } //! +//! /// Optional: a short suffix appended to the App's default +//! /// status message (e.g. portfolio's "[Account: ]"). +//! /// Allocate the returned slice in `arena`. Active tab only. +//! pub fn statusSuffix(state: *State, app: *App, arena: std.mem.Allocator) ?[]const u8 { ... } +//! +//! /// Optional: is the App's active symbol the user-selected +//! /// row in this tab? Drives the tab-bar `*` marker. Active +//! /// tab only; tabs without it default to "not selected". +//! pub fn isSymbolSelected(state: *State, app: *App) bool { ... } +//! +//! /// Optional: release any transmitted Kitty graphics (chart +//! /// images) before vaxis is torn down. Called across ALL tabs +//! /// at App teardown while `app.vx_app` is still valid - distinct +//! /// from `deinit`, which runs after vaxis is already gone. +//! pub fn releaseGraphics(state: *State, app: *App) void { ... } +//! //! /// Optional: does this tab currently have async work in //! /// flight that needs poll-driven redraws? While the ACTIVE //! /// tab answers true, the App keeps a one-shot vxfw Tick @@ -498,6 +514,36 @@ pub fn validateTabModule(comptime Module: type) void { "pub fn wantsPollTick(state: *State, app: *App) bool { ... }", ); } + if (@hasDecl(tab_decl, "isSymbolSelected")) { + validator.expectFn( + "Tab module", + mod_name, + tab_decl, + "isSymbolSelected", + fn (*State, *App) bool, + "pub fn isSymbolSelected(state: *State, app: *App) bool { ... }", + ); + } + if (@hasDecl(tab_decl, "statusSuffix")) { + validator.expectFn( + "Tab module", + mod_name, + tab_decl, + "statusSuffix", + fn (*State, *App, std.mem.Allocator) ?[]const u8, + "pub fn statusSuffix(state: *State, app: *App, arena: std.mem.Allocator) ?[]const u8 { ... }", + ); + } + if (@hasDecl(tab_decl, "releaseGraphics")) { + validator.expectFn( + "Tab module", + mod_name, + tab_decl, + "releaseGraphics", + fn (*State, *App) void, + "pub fn releaseGraphics(state: *State, app: *App) void { ... }", + ); + } // ── Draw hooks (mutually exclusive, exactly one required) ── //