extract help logic from presentation/add tests
This commit is contained in:
parent
ca6683feef
commit
cdf6b9d6e1
4 changed files with 439 additions and 85 deletions
321
src/tui.zig
321
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue