diff --git a/src/tui.zig b/src/tui.zig index 04debea..893a885 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -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() }); diff --git a/src/tui/history_tab.zig b/src/tui/history_tab.zig index 3b6c655..92c7ca7 100644 --- a/src/tui/history_tab.zig +++ b/src/tui/history_tab.zig @@ -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| { diff --git a/src/tui/keybinds.zig b/src/tui/keybinds.zig index 565a2c9..52645ea 100644 --- a/src/tui/keybinds.zig +++ b/src/tui/keybinds.zig @@ -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); + } +} diff --git a/src/tui/options_tab.zig b/src/tui/options_tab.zig index 7254564..6fac9d9 100644 --- a/src/tui/options_tab.zig +++ b/src/tui/options_tab.zig @@ -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() }); diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index 58b4768..c37379e 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -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(""); } } diff --git a/src/tui/projections_tab.zig b/src/tui/projections_tab.zig index 9bbe6ea..7f775f7 100644 --- a/src/tui/projections_tab.zig +++ b/src/tui/projections_tab.zig @@ -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;