extract help logic from presentation/add tests

This commit is contained in:
Emil Lerch 2026-05-15 13:59:36 -07:00
parent ca6683feef
commit cdf6b9d6e1
Signed by: lobo
GPG key ID: A7B62D657EF764F8
4 changed files with 439 additions and 85 deletions

View file

@ -152,6 +152,96 @@ pub const StyledLine = struct {
cell_styles: ?[]const vaxis.Style = null,
};
/// Pre-resolved row in the help overlay: a key string (possibly
/// comma-joined for multiple bindings) plus its label. Used by
/// `buildHelpLines` so the renderer is a pure function over already-
/// resolved data and doesn't need access to the keymap or tab modules.
pub const HelpRow = struct {
keys: []const u8,
label: []const u8,
};
/// One `key label` fragment of the dynamic status-hint line. Used by
/// `formatStatusHint` to compose the full hint as ` | `-joined fragments.
pub const StatusHintFragment = struct {
key: []const u8,
label: []const u8,
};
/// Format the dynamic default status hint from pre-resolved key /
/// label fragments. Each fragment renders as `key label`; fragments
/// are joined with ` | `. Pure function no App access.
pub fn formatStatusHint(
arena: std.mem.Allocator,
fragments: []const StatusHintFragment,
) ![]const u8 {
if (fragments.len == 0) return "";
var pieces: std.ArrayListUnmanaged([]const u8) = .empty;
for (fragments) |f| {
try pieces.append(arena, try std.fmt.allocPrint(arena, "{s} {s}", .{ f.key, f.label }));
}
return std.mem.join(arena, " | ", pieces.items);
}
/// Pre-resolved data passed to `buildHelpLines`. Comprises the
/// global section's rows, the active tab section's rows, and the
/// active tab's display name (without the registry-position prefix).
pub const HelpData = struct {
globals: []const HelpRow,
tab_rows: []const HelpRow,
active_tab_name: []const u8,
};
/// Render the help overlay's styled lines from pre-resolved data.
/// Pure function no App access, no keymap lookup. Easy to test
/// with fixture rows.
pub fn buildHelpLines(
arena: std.mem.Allocator,
th: theme.Theme,
data: HelpData,
) ![]const StyledLine {
var lines: std.ArrayListUnmanaged(StyledLine) = .empty;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " zfin TUI -- Keybindings", .style = th.headerStyle() });
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " Global", .style = th.headerStyle() });
for (data.globals) |row| {
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {s:<20} {s}", .{ row.keys, row.label }),
.style = th.contentStyle(),
});
}
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " Active Tab: {s}", .{data.active_tab_name}),
.style = th.headerStyle(),
});
if (data.tab_rows.len == 0) {
try lines.append(arena, .{
.text = " (no tab-local actions)",
.style = th.mutedStyle(),
});
} else {
for (data.tab_rows) |row| {
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {s:<20} {s}", .{ row.keys, row.label }),
.style = th.contentStyle(),
});
}
}
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " Mouse: click tabs, scroll wheel, click rows", .style = th.mutedStyle() });
try lines.append(arena, .{ .text = " Config: ~/.config/zfin/keys.srf | theme.srf", .style = th.mutedStyle() });
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " Press any key to close.", .style = th.dimStyle() });
return lines.toOwnedSlice(arena);
}
// Tab-specific types
// These logically belong to individual tab files, but live here because
// App's struct fields reference them and Zig requires field types to be
@ -1623,7 +1713,7 @@ pub const App = struct {
/// the active tab's `status_hints` actions resolved against its
/// current bindings. Each fragment is `key label`.
fn buildDefaultStatusHint(self: *App, arena: std.mem.Allocator) ![]const u8 {
var fragments: std.ArrayList([]const u8) = .empty;
var fragments: std.ArrayListUnmanaged(StatusHintFragment) = .empty;
// Always-shown globals. Each fragment uses the FIRST bound
// key for the action; full lists go to the help overlay.
@ -1639,11 +1729,7 @@ pub const App = struct {
for (globals) |g| {
const keys = try self.keysForGlobal(arena, g.action);
if (keys.len == 0) continue;
try fragments.append(arena, try std.fmt.allocPrint(
arena,
"{s} {s}",
.{ keys[0], g.label },
));
try fragments.append(arena, .{ .key = keys[0], .label = g.label });
}
// Active tab's status_hints comptime walk to get the right
@ -1657,16 +1743,12 @@ pub const App = struct {
if (keys.len == 0) continue;
const label = Module.tab.action_labels.get(hint_action);
if (label.len == 0) continue;
try fragments.append(arena, try std.fmt.allocPrint(
arena,
"{s} {s}",
.{ keys[0], label },
));
try fragments.append(arena, .{ .key = keys[0], .label = label });
}
}
}
return std.mem.join(arena, " | ", fragments.items);
return formatStatusHint(arena, fragments.items);
}
pub fn freePortfolioSummary(self: *App) void {
@ -1973,78 +2055,57 @@ pub const App = struct {
// Help
fn buildHelpStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
const th = self.theme;
var lines: std.ArrayList(StyledLine) = .empty;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " zfin TUI -- Keybindings", .style = th.headerStyle() });
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// GLOBAL section
try lines.append(arena, .{ .text = " Global", .style = th.headerStyle() });
// Resolve the help-overlay data upfront (key strings + labels)
// so the renderer is a pure function over the pre-resolved data.
// Active-tab section requires a comptime walk to dispatch on
// `self.active_tab`'s local Action enum; the data we collect
// is type-erased to `[]const HelpRow`.
var globals: std.ArrayListUnmanaged(HelpRow) = .empty;
const global_actions = comptime std.enums.values(keybinds.Action);
for (global_actions) |action| {
const keys = try self.keysForGlobal(arena, action);
if (keys.len == 0) continue;
const keys_str = try std.mem.join(arena, ", ", keys);
const label = keybinds.action_labels.get(action);
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {s:<20} {s}", .{ keys_str, label }),
.style = th.contentStyle(),
try globals.append(arena, .{
.keys = try std.mem.join(arena, ", ", keys),
.label = keybinds.action_labels.get(action),
});
}
// ACTIVE TAB section
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
const active_label = tabLabel(self.active_tab);
// tabLabel returns " {N}:{Name} " with surrounding spaces;
// trim for the header.
const trimmed_label = std.mem.trim(u8, active_label, " ");
const without_number = trimmed_label[std.mem.findScalar(u8, trimmed_label, ':').? + 1 ..];
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " Active Tab: {s}", .{without_number}),
.style = th.headerStyle(),
});
// Comptime walk to dispatch to the active tab's Action enum.
var tab_rows: std.ArrayListUnmanaged(HelpRow) = .empty;
inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
if (std.mem.eql(u8, field.name, @tagName(self.active_tab))) {
const Module = @field(tab_modules, field.name);
const tab_actions = comptime std.enums.values(Module.Action);
if (tab_actions.len == 0) {
try lines.append(arena, .{
.text = " (no tab-local actions)",
.style = th.mutedStyle(),
});
} else {
inline for (tab_actions) |action| {
const action_name = @tagName(action);
const label = Module.tab.action_labels.get(action);
// Skip empty labels (placeholder tabs).
if (label.len > 0) {
const keys = try self.keysForTabAction(arena, field.name, action_name);
const keys_str = if (keys.len == 0)
try arena.dupe(u8, "(unbound)")
else
try std.mem.join(arena, ", ", keys);
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {s:<20} {s}", .{ keys_str, label }),
.style = th.contentStyle(),
});
}
inline for (tab_actions) |action| {
const action_name = @tagName(action);
const label = Module.tab.action_labels.get(action);
if (label.len > 0) {
const keys = try self.keysForTabAction(arena, field.name, action_name);
const keys_str = if (keys.len == 0)
try arena.dupe(u8, "(unbound)")
else
try std.mem.join(arena, ", ", keys);
try tab_rows.append(arena, .{
.keys = keys_str,
.label = label,
});
}
}
}
}
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " Mouse: click tabs, scroll wheel, click rows", .style = th.mutedStyle() });
try lines.append(arena, .{ .text = " Config: ~/.config/zfin/keys.srf | theme.srf", .style = th.mutedStyle() });
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " Press any key to close.", .style = th.dimStyle() });
// Active tab name (without the registry-position prefix) for
// the section header.
const active_tab_label = tabLabel(self.active_tab);
const trimmed = std.mem.trim(u8, active_tab_label, " ");
const colon_pos = std.mem.indexOfScalar(u8, trimmed, ':') orelse 0;
const active_tab_name = if (colon_pos > 0) trimmed[colon_pos + 1 ..] else trimmed;
return lines.toOwnedSlice(arena);
return buildHelpLines(arena, self.theme, .{
.globals = globals.items,
.tab_rows = tab_rows.items,
.active_tab_name = active_tab_name,
});
}
// Tab navigation
@ -2529,3 +2590,129 @@ test "Tab label" {
try testing.expectEqualStrings(" 1:Portfolio ", tabLabel(.portfolio));
try testing.expectEqualStrings(" 6:Analysis ", tabLabel(.analysis));
}
test "buildHelpLines: header, global section, active-tab section, footer" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const data: HelpData = .{
.globals = &.{
.{ .keys = "q, ctrl+c", .label = "Quit" },
.{ .keys = "j", .label = "Select next" },
},
.tab_rows = &.{
.{ .keys = "enter", .label = "Expand position" },
.{ .keys = "s, space", .label = "Select symbol" },
},
.active_tab_name = "Portfolio",
};
const lines = try buildHelpLines(arena, theme.default_theme, data);
var all: std.ArrayListUnmanaged(u8) = .empty;
for (lines) |l| {
try all.appendSlice(arena, l.text);
try all.append(arena, '\n');
}
const text = all.items;
// Title + section headers.
try testing.expect(std.mem.indexOf(u8, text, "zfin TUI -- Keybindings") != null);
try testing.expect(std.mem.indexOf(u8, text, " Global") != null);
try testing.expect(std.mem.indexOf(u8, text, " Active Tab: Portfolio") != null);
// Global rows.
try testing.expect(std.mem.indexOf(u8, text, "q, ctrl+c") != null);
try testing.expect(std.mem.indexOf(u8, text, "Quit") != null);
try testing.expect(std.mem.indexOf(u8, text, "Select next") != null);
// Tab rows.
try testing.expect(std.mem.indexOf(u8, text, "Expand position") != null);
try testing.expect(std.mem.indexOf(u8, text, "s, space") != null);
try testing.expect(std.mem.indexOf(u8, text, "Select symbol") != null);
// Footer.
try testing.expect(std.mem.indexOf(u8, text, "Mouse: click tabs") != null);
try testing.expect(std.mem.indexOf(u8, text, "~/.config/zfin/keys.srf") != null);
try testing.expect(std.mem.indexOf(u8, text, "Press any key to close") != null);
}
test "buildHelpLines: empty tab_rows shows '(no tab-local actions)'" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const data: HelpData = .{
.globals = &.{.{ .keys = "q", .label = "Quit" }},
.tab_rows = &.{},
.active_tab_name = "Earnings",
};
const lines = try buildHelpLines(arena, theme.default_theme, data);
var all: std.ArrayListUnmanaged(u8) = .empty;
for (lines) |l| {
try all.appendSlice(arena, l.text);
try all.append(arena, '\n');
}
const text = all.items;
try testing.expect(std.mem.indexOf(u8, text, "Active Tab: Earnings") != null);
try testing.expect(std.mem.indexOf(u8, text, "(no tab-local actions)") != null);
}
test "buildHelpLines: empty globals still renders title and section headers" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const data: HelpData = .{
.globals = &.{},
.tab_rows = &.{.{ .keys = "x", .label = "Do thing" }},
.active_tab_name = "Quote",
};
const lines = try buildHelpLines(arena, theme.default_theme, data);
var all: std.ArrayListUnmanaged(u8) = .empty;
for (lines) |l| {
try all.appendSlice(arena, l.text);
try all.append(arena, '\n');
}
const text = all.items;
try testing.expect(std.mem.indexOf(u8, text, " Global") != null);
try testing.expect(std.mem.indexOf(u8, text, "Active Tab: Quote") != null);
try testing.expect(std.mem.indexOf(u8, text, "Do thing") != null);
}
test "formatStatusHint: joins fragments with ' | '" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const fragments = [_]StatusHintFragment{
.{ .key = "h", .label = "tabs" },
.{ .key = "j", .label = "select" },
.{ .key = "/", .label = "symbol" },
.{ .key = "?", .label = "help" },
};
const out = try formatStatusHint(arena, &fragments);
try testing.expectEqualStrings("h tabs | j select | / symbol | ? help", out);
}
test "formatStatusHint: empty fragments returns empty string" {
const out = try formatStatusHint(testing.allocator, &.{});
try testing.expectEqualStrings("", out);
}
test "formatStatusHint: single fragment has no separator" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const fragments = [_]StatusHintFragment{
.{ .key = "ctrl+s", .label = "save" },
};
const out = try formatStatusHint(arena, &fragments);
try testing.expectEqualStrings("ctrl+s save", out);
}

View file

@ -497,14 +497,63 @@ fn setSelectionStatus(state: *const State, app: *App) void {
defer arena_state.deinit();
const arena = arena_state.allocator();
const c_keys = app.keysForTabAction(arena, "history", "compare_commit") catch return;
const c_key = c_keys[0];
var buf: [128]u8 = undefined;
const msg = switch (n) {
1 => std.fmt.bufPrint(&buf, "Selected 1 row — select one more + press '{s}' to compare", .{c_key}) catch return,
2 => std.fmt.bufPrint(&buf, "Selected 2 rows — press '{s}' to compare", .{c_key}) catch return,
const msg = formatSelectionStatus(&buf, n, c_keys[0]) catch return;
app.setStatus(msg);
}
/// Format the selection-count status message. Pure function over
/// (count, commit_key). Returns a slice of `buf`.
pub fn formatSelectionStatus(buf: []u8, count: usize, commit_key: []const u8) std.fmt.BufPrintError![]const u8 {
return switch (count) {
1 => std.fmt.bufPrint(buf, "Selected 1 row — select one more + press '{s}' to compare", .{commit_key}),
2 => std.fmt.bufPrint(buf, "Selected 2 rows — press '{s}' to compare", .{commit_key}),
else => unreachable,
};
app.setStatus(msg);
}
/// Format the "select N rows" status hint shown by `commitCompare`
/// when the user presses commit with too few rows selected. Pure
/// function over (count, select_key, commit_key).
pub fn formatCompareNeedMore(buf: []u8, count: usize, select_key: []const u8, commit_key: []const u8) std.fmt.BufPrintError![]const u8 {
return if (count == 0)
std.fmt.bufPrint(buf, "Select two rows with '{s}' (or space), then press '{s}' to compare", .{ select_key, commit_key })
else
std.fmt.bufPrint(buf, "Select one more row with '{s}' (or space), then press '{s}' to compare", .{ select_key, commit_key });
}
/// Format the "Comparing — ... to return" status set when a
/// compare view becomes active. Pure function over the two keys.
pub fn formatCompareActiveStatus(buf: []u8, cancel_key: []const u8, commit_key: []const u8) std.fmt.BufPrintError![]const u8 {
return std.fmt.bufPrint(buf, "Comparing — {s} or '{s}' to return to timeline", .{ cancel_key, commit_key });
}
/// Format the parenthetical key hint shown in the recent-snapshots
/// table header. Pure function over the resolved keys; allocates
/// in `arena`.
pub fn formatTableHeaderHint(
arena: std.mem.Allocator,
j_key: []const u8,
k_key: []const u8,
enter_key: []const u8,
select_key: []const u8,
commit_key: []const u8,
) ![]const u8 {
return std.fmt.allocPrint(
arena,
"({s}/{s}: move, {s}: expand/collapse, {s}/space: select, {s}: compare)",
.{ j_key, k_key, enter_key, select_key, commit_key },
);
}
/// Format the footer hint shown at the bottom of an active compare
/// view. Pure function; allocates in `arena`.
pub fn formatCompareFooterHint(
arena: std.mem.Allocator,
cancel_key: []const u8,
commit_key: []const u8,
) ![]const u8 {
return std.fmt.allocPrint(arena, " {s} or '{s}' to return to timeline", .{ cancel_key, commit_key });
}
// Compare commit
@ -519,13 +568,8 @@ fn commitCompare(state: *State, app: *App) void {
const arena = arena_state.allocator();
const s_keys = app.keysForTabAction(arena, "history", "compare_select") catch return;
const c_keys = app.keysForTabAction(arena, "history", "compare_commit") catch return;
const s_key = s_keys[0];
const c_key = c_keys[0];
var buf: [192]u8 = undefined;
const msg = if (sel_count == 0)
std.fmt.bufPrint(&buf, "Select two rows with '{s}' (or space), then press '{s}' to compare", .{ s_key, c_key }) catch return
else
std.fmt.bufPrint(&buf, "Select one more row with '{s}' (or space), then press '{s}' to compare", .{ s_key, c_key }) catch return;
const msg = formatCompareNeedMore(&buf, sel_count, s_keys[0], c_keys[0]) catch return;
app.setStatus(msg);
return;
}
@ -684,7 +728,7 @@ fn buildCompareFromSelections(state: *State, app: *App, sel_a: usize, sel_b: usi
const cancel_keys = try app.keysForTabAction(arena, "history", "compare_cancel");
const commit_keys = try app.keysForTabAction(arena, "history", "compare_commit");
var status_buf: [128]u8 = undefined;
const msg = std.fmt.bufPrint(&status_buf, "Comparing — {s} or '{s}' to return to timeline", .{ cancel_keys[0], commit_keys[0] }) catch "Comparing — return to timeline";
const msg = formatCompareActiveStatus(&status_buf, cancel_keys[0], commit_keys[0]) catch "Comparing — return to timeline";
app.setStatus(msg);
}
@ -1059,7 +1103,7 @@ pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]c
// cursor state remains sensible if the user exits back.
const cancel_keys = try app.keysForTabAction(arena, "history", "compare_cancel");
const commit_keys = try app.keysForTabAction(arena, "history", "compare_commit");
const footer = try std.fmt.allocPrint(arena, " {s} or '{s}' to return to timeline", .{ cancel_keys[0], commit_keys[0] });
const footer = try formatCompareFooterHint(arena, cancel_keys[0], commit_keys[0]);
return renderCompareLines(arena, app.theme, cv, footer);
}
@ -1071,10 +1115,13 @@ pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]c
const commit_keys = try app.keysForTabAction(arena, "history", "compare_commit");
const j_keys = try app.keysForGlobal(arena, .select_next);
const k_keys = try app.keysForGlobal(arena, .select_prev);
const table_hint = try std.fmt.allocPrint(
const table_hint = try formatTableHeaderHint(
arena,
"({s}/{s}: move, {s}: expand/collapse, {s}/space: select, {s}: compare)",
.{ j_keys[0], k_keys[0], enter_keys[0], select_keys[0], commit_keys[0] },
j_keys[0],
k_keys[0],
enter_keys[0],
select_keys[0],
commit_keys[0],
);
const result = try renderHistoryLinesFull(
arena,
@ -1591,6 +1638,60 @@ const testing = std.testing;
const Date = zfin.Date;
const snapshot = @import("../models/snapshot.zig");
test "formatSelectionStatus: count 1 includes commit key" {
var buf: [128]u8 = undefined;
const msg = try formatSelectionStatus(&buf, 1, "c");
try testing.expectEqualStrings("Selected 1 row — select one more + press 'c' to compare", msg);
}
test "formatSelectionStatus: count 2 includes commit key" {
var buf: [128]u8 = undefined;
const msg = try formatSelectionStatus(&buf, 2, "X");
try testing.expectEqualStrings("Selected 2 rows — press 'X' to compare", msg);
}
test "formatCompareNeedMore: count 0 says 'two rows'" {
var buf: [192]u8 = undefined;
const msg = try formatCompareNeedMore(&buf, 0, "s", "c");
try testing.expectEqualStrings("Select two rows with 's' (or space), then press 'c' to compare", msg);
}
test "formatCompareNeedMore: count 1 says 'one more row'" {
var buf: [192]u8 = undefined;
const msg = try formatCompareNeedMore(&buf, 1, "x", "y");
try testing.expectEqualStrings("Select one more row with 'x' (or space), then press 'y' to compare", msg);
}
test "formatCompareActiveStatus: includes both keys" {
var buf: [128]u8 = undefined;
const msg = try formatCompareActiveStatus(&buf, "Esc", "c");
try testing.expectEqualStrings("Comparing — Esc or 'c' to return to timeline", msg);
}
test "formatTableHeaderHint: includes all five keys" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const msg = try formatTableHeaderHint(arena, "j", "k", "enter", "s", "c");
try testing.expectEqualStrings("(j/k: move, enter: expand/collapse, s/space: select, c: compare)", msg);
}
test "formatTableHeaderHint: respects rebound keys" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const msg = try formatTableHeaderHint(arena, "down", "up", "space", "x", "y");
try testing.expectEqualStrings("(down/up: move, space: expand/collapse, x/space: select, y: compare)", msg);
}
test "formatCompareFooterHint: standard format" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const msg = try formatCompareFooterHint(arena, "Esc", "c");
try testing.expectEqualStrings(" Esc or 'c' to return to timeline", msg);
}
test "renderHistoryLines: no series shows no-data message" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();

View file

@ -428,6 +428,25 @@ fn toggleAllCallsPuts(state: *State, app: *App, is_calls: bool) void {
}
}
/// Format the underlying-price header line shown above the option
/// chains. Pure function over the price, expiration count, current
/// NTM filter, and the resolved filter_1 / filter_9 key strings;
/// allocates in `arena`.
pub fn formatUnderlyingHeader(
arena: std.mem.Allocator,
price: f64,
expiration_count: usize,
near_the_money: usize,
filter_1_key: []const u8,
filter_9_key: []const u8,
) ![]const u8 {
return std.fmt.allocPrint(
arena,
" Underlying: {f} {d} expiration(s) +/- {d} strikes NTM ({s}..{s} to change)",
.{ Money.from(price), expiration_count, near_the_money, filter_1_key, filter_9_key },
);
}
// Rendering
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
@ -469,7 +488,8 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine
// user-rebound keymap shows the actual range.
const f1_keys = try app.keysForTabAction(arena, "options", "filter_1");
const f9_keys = try app.keysForTabAction(arena, "options", "filter_9");
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Underlying: {f} {d} expiration(s) +/- {d} strikes NTM ({s}..{s} to change)", .{ Money.from(price), chains.len, state.near_the_money, f1_keys[0], f9_keys[0] }), .style = th.contentStyle() });
const text = try formatUnderlyingHeader(arena, price, chains.len, state.near_the_money, f1_keys[0], f9_keys[0]);
try lines.append(arena, .{ .text = text, .style = th.contentStyle() });
}
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
@ -541,3 +561,26 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine
return lines.toOwnedSlice(arena);
}
// Tests
const testing = std.testing;
test "formatUnderlyingHeader: includes price, expirations, NTM, and key range" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const text = try formatUnderlyingHeader(arena, 175.42, 7, 5, "ctrl+1", "ctrl+9");
try testing.expect(std.mem.indexOf(u8, text, "$175.42") != null);
try testing.expect(std.mem.indexOf(u8, text, "7 expiration(s)") != null);
try testing.expect(std.mem.indexOf(u8, text, "+/- 5 strikes NTM") != null);
try testing.expect(std.mem.indexOf(u8, text, "(ctrl+1..ctrl+9 to change)") != null);
}
test "formatUnderlyingHeader: respects rebound keys" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const text = try formatUnderlyingHeader(arena, 100.0, 3, 1, "F1", "F9");
try testing.expect(std.mem.indexOf(u8, text, "(F1..F9 to change)") != null);
}

View file

@ -193,7 +193,7 @@ pub const tab = struct {
const arena = arena_state.allocator();
const d_keys = app.keysForTabAction(arena, "projections", "as_of_input") catch return;
var buf: [128]u8 = undefined;
const msg = std.fmt.bufPrint(&buf, "Overlay only available with --as-of (press {s} to set)", .{d_keys[0]}) catch return;
const msg = formatOverlayUnavailable(&buf, d_keys[0]) catch return;
app.setStatus(msg);
return;
}
@ -252,6 +252,13 @@ pub const tab = struct {
}
};
/// Format the "overlay unavailable" status hint shown when the user
/// presses the overlay-toggle key while no as-of date is set. Pure
/// function over the as-of-input key string.
pub fn formatOverlayUnavailable(buf: []u8, as_of_input_key: []const u8) std.fmt.BufPrintError![]const u8 {
return std.fmt.bufPrint(buf, "Overlay only available with --as-of (press {s} to set)", .{as_of_input_key});
}
// Data loading
pub fn loadData(state: *State, app: *App) void {
@ -1442,3 +1449,19 @@ fn appendReturnRow(
.style = style,
});
}
// Tests
const testing = std.testing;
test "formatOverlayUnavailable: includes resolved as-of-input key" {
var buf: [128]u8 = undefined;
const msg = try formatOverlayUnavailable(&buf, "d");
try testing.expectEqualStrings("Overlay only available with --as-of (press d to set)", msg);
}
test "formatOverlayUnavailable: respects rebound as-of-input key" {
var buf: [128]u8 = undefined;
const msg = try formatOverlayUnavailable(&buf, "ctrl+d");
try testing.expectEqualStrings("Overlay only available with --as-of (press ctrl+d to set)", msg);
}