begin help cleanup

This commit is contained in:
Emil Lerch 2026-05-15 13:17:33 -07:00
parent 15a3304e19
commit db70b1f924
Signed by: lobo
GPG key ID: A7B62D657EF764F8
6 changed files with 313 additions and 71 deletions

View file

@ -889,6 +889,86 @@ pub const App = struct {
return false;
}
/// Return all formatted key strings bound to the given global
/// `action`. Allocated in `arena`. Empty slice if no binding
/// exists. Order matches the keymap's binding order.
///
/// Used by the help overlay and dynamic status hints to render
/// "actual current key" rather than hardcoded literals so that
/// rebinding a key in `keys.srf` updates the displayed name.
pub fn keysForGlobal(self: *const App, arena: std.mem.Allocator, action: keybinds.Action) ![][]const u8 {
var out: std.ArrayList([]const u8) = .empty;
for (self.keymap.bindings) |b| {
if (b.action != action) continue;
var key_buf: [32]u8 = undefined;
const s = keybinds.formatKeyCombo(b.key, &key_buf) orelse continue;
try out.append(arena, try arena.dupe(u8, s));
}
return out.toOwnedSlice(arena);
}
/// Return all formatted key strings bound to the named tab-local
/// action in the given tab's keymap. Looks up user overrides
/// first (`tabOverridesFor(scope)`), falling back to the tab
/// module's `default_bindings`. The action name is matched against
/// variant names of the tab's `Action` enum.
///
/// `scope` is the tab tag name (e.g. `"options"`, `"history"`)
/// matching the `tab_modules` registry. Comptime so we can resolve
/// the tab's Action enum type.
pub fn keysForTabAction(
self: *const App,
arena: std.mem.Allocator,
comptime scope: []const u8,
action_tag_name: []const u8,
) ![][]const u8 {
const Module = @field(tab_modules, scope);
var out: std.ArrayList([]const u8) = .empty;
// Prefer user overrides for this scope when present.
if (self.keymap.tabOverridesFor(scope)) |overrides| {
for (overrides) |ovr| {
if (!std.mem.eql(u8, ovr.action_name, action_tag_name)) continue;
var key_buf: [32]u8 = undefined;
const s = keybinds.formatKeyCombo(ovr.key, &key_buf) orelse continue;
try out.append(arena, try arena.dupe(u8, s));
}
return out.toOwnedSlice(arena);
}
// No overrides read from the tab's default_bindings.
for (Module.tab.default_bindings) |binding| {
if (!std.mem.eql(u8, @tagName(binding.action), action_tag_name)) continue;
// Tab modules use `tab_framework.KeyCombo`, which is
// structurally identical to but type-distinct from
// `keybinds.KeyCombo`. Repack for `formatKeyCombo`.
const combo: keybinds.KeyCombo = .{
.codepoint = binding.key.codepoint,
.mods = binding.key.mods,
};
var key_buf: [32]u8 = undefined;
const s = keybinds.formatKeyCombo(combo, &key_buf) orelse continue;
try out.append(arena, try arena.dupe(u8, s));
}
return out.toOwnedSlice(arena);
}
/// Convenience: like `keysForTabAction` but resolves to whichever
/// tab is currently active. Comptime-walks `tab_modules` to find
/// the matching scope.
fn keysForActiveTabAction(
self: *const App,
arena: std.mem.Allocator,
action_tag_name: []const u8,
) ![][]const u8 {
inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
if (std.mem.eql(u8, field.name, @tagName(self.active_tab))) {
return self.keysForTabAction(arena, field.name, action_tag_name);
}
}
return &.{};
}
/// Outcome of a single keypress in an input-mode buffer (symbol
/// input, date input, etc.). Returned by `handleInputBuffer` so
/// the per-mode caller only needs to wire up the `committed`
@ -1527,9 +1607,66 @@ pub const App = struct {
self.status_len = len;
}
fn getStatus(self: *App) []const u8 {
if (self.status_len == 0) return "h/l tabs | j/k select | Enter expand | s select | / symbol | ? help";
return self.status_msg[0..self.status_len];
/// Returns the current status message. When no message has been
/// set, builds a dynamic default hint composed from a small set
/// of always-shown global keys plus the active tab's
/// `status_hints`. Allocated in `arena` for the dynamic default;
/// the user-set buffer is returned by reference.
fn getStatus(self: *App, arena: std.mem.Allocator) []const u8 {
if (self.status_len > 0) return self.status_msg[0..self.status_len];
return self.buildDefaultStatusHint(arena) catch
"h/l tabs | j/k select | / symbol | ? help";
}
/// Build the dynamic default status hint: a small set of always-
/// shown global keys (tab nav, cursor, symbol input, help) plus
/// 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;
// Always-shown globals. Each fragment uses the FIRST bound
// key for the action; full lists go to the help overlay.
const globals = [_]struct {
action: keybinds.Action,
label: []const u8,
}{
.{ .action = .prev_tab, .label = "tabs" },
.{ .action = .select_next, .label = "select" },
.{ .action = .symbol_input, .label = "symbol" },
.{ .action = .help, .label = "help" },
};
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 },
));
}
// Active tab's status_hints comptime walk to get the right
// Action enum + label table.
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);
for (Module.tab.status_hints) |hint_action| {
const action_name = @tagName(hint_action);
const keys = try self.keysForTabAction(arena, field.name, action_name);
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 },
));
}
}
}
return std.mem.join(arena, " | ", fragments.items);
}
pub fn freePortfolioSummary(self: *App) void {
@ -1775,13 +1912,13 @@ pub const App = struct {
// Show account filter indicator when active, appended to status message
if (self.states.portfolio.account_filter != null and self.active_tab == .portfolio) {
const af = self.states.portfolio.account_filter.?;
const msg = self.getStatus();
const msg = self.getStatus(ctx.arena);
const filter_text = std.fmt.allocPrint(ctx.arena, "{s} [Account: {s}]", .{ msg, af }) catch msg;
for (0..@min(filter_text.len, width)) |i| {
buf[i] = .{ .char = .{ .grapheme = glyph(filter_text[i]) }, .style = status_style };
}
} else {
const msg = self.getStatus();
const msg = self.getStatus(ctx.arena);
for (0..@min(msg.len, width)) |i| {
buf[i] = .{ .char = .{ .grapheme = glyph(msg[i]) }, .style = status_style };
}
@ -1843,54 +1980,61 @@ pub const App = struct {
try lines.append(arena, .{ .text = " zfin TUI -- Keybindings", .style = th.headerStyle() });
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
const actions = comptime std.enums.values(keybinds.Action);
const action_labels = [_][]const u8{
"Quit", "Refresh", "Previous tab", "Next tab",
"Tab 1", "Tab 2", "Tab 3", "Tab 4",
"Tab 5", "Tab 6", "Tab 7", "Scroll down",
"Scroll up", "Scroll to top", "Scroll to bottom", "Page down",
"Page up", "Select next", "Select prev", "Expand/collapse",
"Select symbol", "Change symbol (search)", "This help", "Reload portfolio from disk",
"Toggle all calls (options)", "Toggle all puts (options)", "Filter +/- 1 NTM", "Filter +/- 2 NTM",
"Filter +/- 3 NTM", "Filter +/- 4 NTM", "Filter +/- 5 NTM", "Filter +/- 6 NTM",
"Filter +/- 7 NTM", "Filter +/- 8 NTM", "Filter +/- 9 NTM", "Chart: next timeframe",
"Chart: prev timeframe", "History: cycle metric", "Sort: next column", "Sort: prev column",
"Sort: reverse order", "Account filter (portfolio)",
};
// GLOBAL section
try lines.append(arena, .{ .text = " GLOBAL", .style = th.headerStyle() });
for (actions, 0..) |action, ai| {
var key_strs: [8][]const u8 = undefined;
var key_count: usize = 0;
for (self.keymap.bindings) |b| {
if (b.action == action and key_count < key_strs.len) {
var key_buf: [32]u8 = undefined;
if (keybinds.formatKeyCombo(b.key, &key_buf)) |s| {
key_strs[key_count] = try arena.dupe(u8, s);
key_count += 1;
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(),
});
}
// 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, " ");
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " ACTIVE TAB: {s}", .{trimmed_label}),
.style = th.headerStyle(),
});
// Comptime walk to dispatch to the active tab's Action enum.
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(),
});
}
}
}
}
if (key_count == 0) continue;
var combined_buf: [128]u8 = undefined;
var pos: usize = 0;
for (0..key_count) |ki| {
if (ki > 0) {
if (pos + 2 <= combined_buf.len) {
combined_buf[pos] = ',';
combined_buf[pos + 1] = ' ';
pos += 2;
}
}
const ks = key_strs[ki];
if (pos + ks.len <= combined_buf.len) {
@memcpy(combined_buf[pos..][0..ks.len], ks);
pos += ks.len;
}
}
const label_text = if (ai < action_labels.len) action_labels[ai] else @tagName(action);
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s}", .{ combined_buf[0..pos], label_text }), .style = th.contentStyle() });
}
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });

View file

@ -489,12 +489,22 @@ fn clearSelections(state: *State) void {
fn setSelectionStatus(state: *const State, app: *App) void {
const n = selectionCount(state);
switch (n) {
0 => app.setStatus(""),
1 => app.setStatus("Selected 1 row — select one more + press 'c' to compare"),
2 => app.setStatus("Selected 2 rows — press 'c' to compare"),
else => unreachable,
if (n == 0) {
app.setStatus("");
return;
}
var arena_state = std.heap.ArenaAllocator.init(app.allocator);
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,
else => unreachable,
};
app.setStatus(msg);
}
// Compare commit
@ -504,11 +514,19 @@ fn setSelectionStatus(state: *const State, app: *App) void {
fn commitCompare(state: *State, app: *App) void {
const sel_count = selectionCount(state);
if (sel_count < 2) {
if (sel_count == 0) {
app.setStatus("Select two rows with 's' (or space), then press 'c' to compare");
} else {
app.setStatus("Select one more row with 's' (or space), then press 'c' to compare");
}
var arena_state = std.heap.ArenaAllocator.init(app.allocator);
defer arena_state.deinit();
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;
app.setStatus(msg);
return;
}
@ -663,7 +681,11 @@ fn buildCompareFromSelections(state: *State, app: *App, sel_a: usize, sel_b: usi
clearCompareView(state, app);
state.compare_view = cv_with_labels;
state.compare_resources = resources;
app.setStatus("Comparing — Esc or 'c' to return to timeline");
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";
app.setStatus(msg);
}
fn liveLiquid(app: *const App) f64 {
@ -1035,10 +1057,25 @@ pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]c
if (state.compare_view) |cv| {
// Compare view doesn't populate table metadata; reset so the
// cursor state remains sensible if the user exits back.
return renderCompareLines(arena, app.theme, cv);
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] });
return renderCompareLines(arena, app.theme, cv, footer);
}
const rows = try collectTableRows(arena, state, app);
// Resolve key bindings for the table-header hint so the displayed
// keys reflect the user's actual keymap (defaults or overridden).
const enter_keys = try app.keysForTabAction(arena, "history", "expand_collapse");
const select_keys = try app.keysForTabAction(arena, "history", "compare_select");
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(
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] },
);
const result = try renderHistoryLinesFull(
arena,
app.theme,
@ -1048,6 +1085,7 @@ pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]c
rows,
state.cursor,
state.selections,
table_hint,
);
// Stash table metadata on State for the event handler's cursor-
@ -1086,6 +1124,7 @@ pub fn renderHistoryLines(
rows,
0,
.{ null, null },
null,
);
return result.lines;
}
@ -1144,6 +1183,11 @@ pub fn renderHistoryLinesFull(
rows: []const TableRow,
cursor: usize,
selections: [2]?usize,
/// Optional override for the parenthetical key hints in the
/// "Recent snapshots" header. When null, falls back to the
/// default-keymap literal. Pass an arena-allocated string from
/// `buildStyledLines` for live-resolved keys.
table_header_hint: ?[]const u8,
) !HistoryRender {
var lines: std.ArrayList(StyledLine) = .empty;
@ -1211,10 +1255,11 @@ pub fn renderHistoryLinesFull(
var rlabel_buf: [32]u8 = undefined;
const rlabel = view.fmtResolutionLabel(&rlabel_buf, resolution_override, resolution);
const hint_text = table_header_hint orelse "(j/k: move, enter: expand/collapse, s/space: select, c: compare)";
const table_header = try std.fmt.allocPrint(
arena,
" Recent snapshots {s} (j/k: move, enter: expand/collapse, s/space: select, c: compare)",
.{rlabel},
" Recent snapshots {s} {s}",
.{ rlabel, hint_text },
);
try lines.append(arena, .{ .text = table_header, .style = th.headerStyle() });
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
@ -1444,6 +1489,7 @@ pub fn renderCompareLines(
arena: std.mem.Allocator,
th: theme.Theme,
cv: compare_view.CompareView,
footer_hint: []const u8,
) ![]const StyledLine {
var lines: std.ArrayList(StyledLine) = .empty;
@ -1532,7 +1578,7 @@ pub fn renderCompareLines(
// Footer hint
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{
.text = " Esc or 'c' to return to timeline",
.text = footer_hint,
.style = th.mutedStyle(),
});
@ -1782,6 +1828,7 @@ test "renderHistoryLinesFull: cursor highlights selected row" {
rows,
1,
.{ 0, 2 },
null,
);
try testing.expectEqual(@as(usize, 3), result.table_row_count);
@ -1832,7 +1879,7 @@ test "renderCompareLines: emits header, totals, symbols, hidden-count, footer" {
.removed_count = 0,
};
const lines = try renderCompareLines(a, th, cv);
const lines = try renderCompareLines(a, th, cv, " Esc or 'c' to return to timeline");
var saw_header = false;
var saw_totals = false;
@ -1870,7 +1917,7 @@ test "renderCompareLines: live-now shows 'today' not a date" {
.added_count = 0,
.removed_count = 0,
};
const lines = try renderCompareLines(a, th, cv);
const lines = try renderCompareLines(a, th, cv, " Esc or 'c' to return to timeline");
var saw_today = false;
var saw_no_symbols = false;
@ -1899,7 +1946,7 @@ test "renderCompareLines: no hidden line when no add/remove" {
.added_count = 0,
.removed_count = 0,
};
const lines = try renderCompareLines(a, th, cv);
const lines = try renderCompareLines(a, th, cv, " Esc or 'c' to return to timeline");
for (lines) |l| {
try testing.expect(std.mem.indexOf(u8, l.text, "hidden") == null);
@ -1930,7 +1977,7 @@ test "renderCompareLines: bucket labels override ISO dates in header" {
.then_label = "Q1 2025 (ended 2025-03-28)",
.now_label = null,
};
const lines = try renderCompareLines(a, th, cv);
const lines = try renderCompareLines(a, th, cv, " Esc or 'c' to return to timeline");
var found_header = false;
for (lines) |l| {

View file

@ -148,6 +148,36 @@ pub fn defaults() KeyMap {
return .{ .bindings = &global_default_bindings };
}
/// Display labels for each global Action variant. Used by the help
/// overlay and status-line hint to render human-readable names
/// alongside the resolved key bindings. Parallel in shape to each
/// tab module's own `tab.action_labels`.
pub const action_labels = std.enums.EnumArray(Action, []const u8).init(.{
.quit = "Quit",
.refresh = "Refresh",
.prev_tab = "Previous tab",
.next_tab = "Next tab",
.tab_1 = "Tab 1",
.tab_2 = "Tab 2",
.tab_3 = "Tab 3",
.tab_4 = "Tab 4",
.tab_5 = "Tab 5",
.tab_6 = "Tab 6",
.tab_7 = "Tab 7",
.tab_8 = "Tab 8",
.scroll_down = "Half page down",
.scroll_up = "Half page up",
.scroll_top = "Scroll to top",
.scroll_bottom = "Scroll to bottom",
.page_down = "Page down",
.page_up = "Page up",
.select_next = "Select next",
.select_prev = "Select previous",
.symbol_input = "Symbol input",
.help = "Help",
.reload_portfolio = "Reload portfolio",
});
// SRF serialization
const special_key_names = [_]struct { name: []const u8, cp: u21 }{
@ -653,3 +683,11 @@ test "defaults returns valid keymap" {
}
try std.testing.expect(found_quit);
}
test "action_labels: every Action variant has a non-empty label" {
inline for (std.meta.fields(Action)) |f| {
const action: Action = @enumFromInt(f.value);
const label = action_labels.get(action);
try std.testing.expect(label.len > 0);
}
}

View file

@ -465,7 +465,11 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine
}
if (chains[0].underlying_price) |price| {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Underlying: {f} {d} expiration(s) +/- {d} strikes NTM (Ctrl+1-9 to change)", .{ Money.from(price), chains.len, state.near_the_money }), .style = th.contentStyle() });
// Resolve filter_1 and filter_9 keys for the hint, so a
// 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() });
}
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });

View file

@ -601,7 +601,10 @@ pub fn loadPortfolioData(state: *State, app: *App) void {
const info_msg = std.fmt.bufPrint(&info_buf, "Loaded {d} symbols ({d} fetched) | r/F5 to refresh", .{ syms.len, fetch_count }) catch "Loaded | r/F5 to refresh";
app.setStatus(info_msg);
} else {
app.setStatus("j/k navigate | Enter expand | s select symbol | / search | ? help");
// Empty status App's getStatus() will fall back to the
// dynamic default hint composed from the active tab's
// status_hints + global keys.
app.setStatus("");
}
}

View file

@ -188,7 +188,13 @@ pub const tab = struct {
switch (action) {
.overlay_actuals => {
if (state.as_of == null) {
app.setStatus("Overlay only available with --as-of (press d to set)");
var arena_state = std.heap.ArenaAllocator.init(app.allocator);
defer arena_state.deinit();
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;
app.setStatus(msg);
return;
}
state.overlay_actuals = !state.overlay_actuals;