extract help logic from presentation/add tests
This commit is contained in:
parent
7ff1d4f833
commit
f089649b7f
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,
|
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 ───────────────────────────────────────────
|
// ── Tab-specific types ───────────────────────────────────────────
|
||||||
// These logically belong to individual tab files, but live here because
|
// These logically belong to individual tab files, but live here because
|
||||||
// App's struct fields reference them and Zig requires field types to be
|
// 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
|
/// the active tab's `status_hints` actions resolved against its
|
||||||
/// current bindings. Each fragment is `key label`.
|
/// current bindings. Each fragment is `key label`.
|
||||||
fn buildDefaultStatusHint(self: *App, arena: std.mem.Allocator) ![]const u8 {
|
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
|
// Always-shown globals. Each fragment uses the FIRST bound
|
||||||
// key for the action; full lists go to the help overlay.
|
// key for the action; full lists go to the help overlay.
|
||||||
|
|
@ -1639,11 +1729,7 @@ pub const App = struct {
|
||||||
for (globals) |g| {
|
for (globals) |g| {
|
||||||
const keys = try self.keysForGlobal(arena, g.action);
|
const keys = try self.keysForGlobal(arena, g.action);
|
||||||
if (keys.len == 0) continue;
|
if (keys.len == 0) continue;
|
||||||
try fragments.append(arena, try std.fmt.allocPrint(
|
try fragments.append(arena, .{ .key = keys[0], .label = g.label });
|
||||||
arena,
|
|
||||||
"{s} {s}",
|
|
||||||
.{ keys[0], g.label },
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Active tab's status_hints — comptime walk to get the right
|
// Active tab's status_hints — comptime walk to get the right
|
||||||
|
|
@ -1657,16 +1743,12 @@ pub const App = struct {
|
||||||
if (keys.len == 0) continue;
|
if (keys.len == 0) continue;
|
||||||
const label = Module.tab.action_labels.get(hint_action);
|
const label = Module.tab.action_labels.get(hint_action);
|
||||||
if (label.len == 0) continue;
|
if (label.len == 0) continue;
|
||||||
try fragments.append(arena, try std.fmt.allocPrint(
|
try fragments.append(arena, .{ .key = keys[0], .label = label });
|
||||||
arena,
|
|
||||||
"{s} {s}",
|
|
||||||
.{ keys[0], label },
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return std.mem.join(arena, " | ", fragments.items);
|
return formatStatusHint(arena, fragments.items);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn freePortfolioSummary(self: *App) void {
|
pub fn freePortfolioSummary(self: *App) void {
|
||||||
|
|
@ -1973,78 +2055,57 @@ pub const App = struct {
|
||||||
// ── Help ─────────────────────────────────────────────────────
|
// ── Help ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn buildHelpStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
fn buildHelpStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
||||||
const th = self.theme;
|
// Resolve the help-overlay data upfront (key strings + labels)
|
||||||
var lines: std.ArrayList(StyledLine) = .empty;
|
// so the renderer is a pure function over the pre-resolved data.
|
||||||
|
// Active-tab section requires a comptime walk to dispatch on
|
||||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
// `self.active_tab`'s local Action enum; the data we collect
|
||||||
try lines.append(arena, .{ .text = " zfin TUI -- Keybindings", .style = th.headerStyle() });
|
// is type-erased to `[]const HelpRow`.
|
||||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
var globals: std.ArrayListUnmanaged(HelpRow) = .empty;
|
||||||
|
|
||||||
// ── GLOBAL section ─────────────────────────────────────
|
|
||||||
try lines.append(arena, .{ .text = " Global", .style = th.headerStyle() });
|
|
||||||
|
|
||||||
const global_actions = comptime std.enums.values(keybinds.Action);
|
const global_actions = comptime std.enums.values(keybinds.Action);
|
||||||
for (global_actions) |action| {
|
for (global_actions) |action| {
|
||||||
const keys = try self.keysForGlobal(arena, action);
|
const keys = try self.keysForGlobal(arena, action);
|
||||||
if (keys.len == 0) continue;
|
if (keys.len == 0) continue;
|
||||||
const keys_str = try std.mem.join(arena, ", ", keys);
|
try globals.append(arena, .{
|
||||||
const label = keybinds.action_labels.get(action);
|
.keys = try std.mem.join(arena, ", ", keys),
|
||||||
try lines.append(arena, .{
|
.label = keybinds.action_labels.get(action),
|
||||||
.text = try std.fmt.allocPrint(arena, " {s:<20} {s}", .{ keys_str, label }),
|
|
||||||
.style = th.contentStyle(),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ACTIVE TAB section ─────────────────────────────────
|
var tab_rows: std.ArrayListUnmanaged(HelpRow) = .empty;
|
||||||
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.
|
|
||||||
inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
|
inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
|
||||||
if (std.mem.eql(u8, field.name, @tagName(self.active_tab))) {
|
if (std.mem.eql(u8, field.name, @tagName(self.active_tab))) {
|
||||||
const Module = @field(tab_modules, field.name);
|
const Module = @field(tab_modules, field.name);
|
||||||
const tab_actions = comptime std.enums.values(Module.Action);
|
const tab_actions = comptime std.enums.values(Module.Action);
|
||||||
if (tab_actions.len == 0) {
|
inline for (tab_actions) |action| {
|
||||||
try lines.append(arena, .{
|
const action_name = @tagName(action);
|
||||||
.text = " (no tab-local actions)",
|
const label = Module.tab.action_labels.get(action);
|
||||||
.style = th.mutedStyle(),
|
if (label.len > 0) {
|
||||||
});
|
const keys = try self.keysForTabAction(arena, field.name, action_name);
|
||||||
} else {
|
const keys_str = if (keys.len == 0)
|
||||||
inline for (tab_actions) |action| {
|
try arena.dupe(u8, "(unbound)")
|
||||||
const action_name = @tagName(action);
|
else
|
||||||
const label = Module.tab.action_labels.get(action);
|
try std.mem.join(arena, ", ", keys);
|
||||||
// Skip empty labels (placeholder tabs).
|
try tab_rows.append(arena, .{
|
||||||
if (label.len > 0) {
|
.keys = keys_str,
|
||||||
const keys = try self.keysForTabAction(arena, field.name, action_name);
|
.label = label,
|
||||||
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(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
// Active tab name (without the registry-position prefix) for
|
||||||
try lines.append(arena, .{ .text = " Mouse: click tabs, scroll wheel, click rows", .style = th.mutedStyle() });
|
// the section header.
|
||||||
try lines.append(arena, .{ .text = " Config: ~/.config/zfin/keys.srf | theme.srf", .style = th.mutedStyle() });
|
const active_tab_label = tabLabel(self.active_tab);
|
||||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
const trimmed = std.mem.trim(u8, active_tab_label, " ");
|
||||||
try lines.append(arena, .{ .text = " Press any key to close.", .style = th.dimStyle() });
|
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 ───────────────────────────────────────────
|
// ── Tab navigation ───────────────────────────────────────────
|
||||||
|
|
@ -2529,3 +2590,129 @@ test "Tab label" {
|
||||||
try testing.expectEqualStrings(" 1:Portfolio ", tabLabel(.portfolio));
|
try testing.expectEqualStrings(" 1:Portfolio ", tabLabel(.portfolio));
|
||||||
try testing.expectEqualStrings(" 6:Analysis ", tabLabel(.analysis));
|
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();
|
defer arena_state.deinit();
|
||||||
const arena = arena_state.allocator();
|
const arena = arena_state.allocator();
|
||||||
const c_keys = app.keysForTabAction(arena, "history", "compare_commit") catch return;
|
const c_keys = app.keysForTabAction(arena, "history", "compare_commit") catch return;
|
||||||
const c_key = c_keys[0];
|
|
||||||
var buf: [128]u8 = undefined;
|
var buf: [128]u8 = undefined;
|
||||||
const msg = switch (n) {
|
const msg = formatSelectionStatus(&buf, n, c_keys[0]) catch return;
|
||||||
1 => std.fmt.bufPrint(&buf, "Selected 1 row — select one more + press '{s}' to compare", .{c_key}) catch return,
|
app.setStatus(msg);
|
||||||
2 => std.fmt.bufPrint(&buf, "Selected 2 rows — press '{s}' to compare", .{c_key}) catch return,
|
}
|
||||||
|
|
||||||
|
/// 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,
|
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 ───────────────────────────────────────────
|
// ── Compare commit ───────────────────────────────────────────
|
||||||
|
|
@ -519,13 +568,8 @@ fn commitCompare(state: *State, app: *App) void {
|
||||||
const arena = arena_state.allocator();
|
const arena = arena_state.allocator();
|
||||||
const s_keys = app.keysForTabAction(arena, "history", "compare_select") catch return;
|
const s_keys = app.keysForTabAction(arena, "history", "compare_select") catch return;
|
||||||
const c_keys = app.keysForTabAction(arena, "history", "compare_commit") 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;
|
var buf: [192]u8 = undefined;
|
||||||
const msg = if (sel_count == 0)
|
const msg = formatCompareNeedMore(&buf, sel_count, s_keys[0], c_keys[0]) catch return;
|
||||||
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;
|
|
||||||
app.setStatus(msg);
|
app.setStatus(msg);
|
||||||
return;
|
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 cancel_keys = try app.keysForTabAction(arena, "history", "compare_cancel");
|
||||||
const commit_keys = try app.keysForTabAction(arena, "history", "compare_commit");
|
const commit_keys = try app.keysForTabAction(arena, "history", "compare_commit");
|
||||||
var status_buf: [128]u8 = undefined;
|
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);
|
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.
|
// cursor state remains sensible if the user exits back.
|
||||||
const cancel_keys = try app.keysForTabAction(arena, "history", "compare_cancel");
|
const cancel_keys = try app.keysForTabAction(arena, "history", "compare_cancel");
|
||||||
const commit_keys = try app.keysForTabAction(arena, "history", "compare_commit");
|
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);
|
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 commit_keys = try app.keysForTabAction(arena, "history", "compare_commit");
|
||||||
const j_keys = try app.keysForGlobal(arena, .select_next);
|
const j_keys = try app.keysForGlobal(arena, .select_next);
|
||||||
const k_keys = try app.keysForGlobal(arena, .select_prev);
|
const k_keys = try app.keysForGlobal(arena, .select_prev);
|
||||||
const table_hint = try std.fmt.allocPrint(
|
const table_hint = try formatTableHeaderHint(
|
||||||
arena,
|
arena,
|
||||||
"({s}/{s}: move, {s}: expand/collapse, {s}/space: select, {s}: compare)",
|
j_keys[0],
|
||||||
.{ j_keys[0], k_keys[0], enter_keys[0], select_keys[0], commit_keys[0] },
|
k_keys[0],
|
||||||
|
enter_keys[0],
|
||||||
|
select_keys[0],
|
||||||
|
commit_keys[0],
|
||||||
);
|
);
|
||||||
const result = try renderHistoryLinesFull(
|
const result = try renderHistoryLinesFull(
|
||||||
arena,
|
arena,
|
||||||
|
|
@ -1591,6 +1638,60 @@ const testing = std.testing;
|
||||||
const Date = zfin.Date;
|
const Date = zfin.Date;
|
||||||
const snapshot = @import("../models/snapshot.zig");
|
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" {
|
test "renderHistoryLines: no series shows no-data message" {
|
||||||
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
||||||
defer arena.deinit();
|
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 ─────────────────────────────────────────────────
|
// ── Rendering ─────────────────────────────────────────────────
|
||||||
|
|
||||||
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
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.
|
// user-rebound keymap shows the actual range.
|
||||||
const f1_keys = try app.keysForTabAction(arena, "options", "filter_1");
|
const f1_keys = try app.keysForTabAction(arena, "options", "filter_1");
|
||||||
const f9_keys = try app.keysForTabAction(arena, "options", "filter_9");
|
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() });
|
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);
|
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 arena = arena_state.allocator();
|
||||||
const d_keys = app.keysForTabAction(arena, "projections", "as_of_input") catch return;
|
const d_keys = app.keysForTabAction(arena, "projections", "as_of_input") catch return;
|
||||||
var buf: [128]u8 = undefined;
|
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);
|
app.setStatus(msg);
|
||||||
return;
|
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 ──────────────────────────────────────────────
|
// ── Data loading ──────────────────────────────────────────────
|
||||||
|
|
||||||
pub fn loadData(state: *State, app: *App) void {
|
pub fn loadData(state: *State, app: *App) void {
|
||||||
|
|
@ -1442,3 +1449,19 @@ fn appendReturnRow(
|
||||||
.style = style,
|
.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