diff --git a/src/tui.zig b/src/tui.zig index 48e50c2..8e595f1 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -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); +} diff --git a/src/tui/history_tab.zig b/src/tui/history_tab.zig index 92c7ca7..4b57306 100644 --- a/src/tui/history_tab.zig +++ b/src/tui/history_tab.zig @@ -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(); diff --git a/src/tui/options_tab.zig b/src/tui/options_tab.zig index 6fac9d9..079370a 100644 --- a/src/tui/options_tab.zig +++ b/src/tui/options_tab.zig @@ -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); +} diff --git a/src/tui/projections_tab.zig b/src/tui/projections_tab.zig index 7f775f7..59111cf 100644 --- a/src/tui/projections_tab.zig +++ b/src/tui/projections_tab.zig @@ -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); +}