clean remaining tui.zig "reach-ins"

This commit is contained in:
Emil Lerch 2026-06-27 07:57:43 -07:00
parent bfabd66866
commit 2bd49af8f3
Signed by: lobo
GPG key ID: A7B62D657EF764F8
5 changed files with 165 additions and 28 deletions

View file

@ -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(), .{});
}

View file

@ -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;

View file

@ -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`,

View file

@ -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

View file

@ -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: <filter>]").
//! /// 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)
//