From ca6683feefa80b9b0ea865aa53c09938ad78bfa3 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Fri, 15 May 2026 13:40:24 -0700 Subject: [PATCH] portfolio tab help cleanup --- src/tui.zig | 5 +- src/tui/portfolio_tab.zig | 224 +++++++++++++++++++++++++++++++++----- 2 files changed, 200 insertions(+), 29 deletions(-) diff --git a/src/tui.zig b/src/tui.zig index 893a885..48e50c2 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -1981,7 +1981,7 @@ pub const App = struct { try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); // ── GLOBAL section ───────────────────────────────────── - try lines.append(arena, .{ .text = " GLOBAL", .style = th.headerStyle() }); + try lines.append(arena, .{ .text = " Global", .style = th.headerStyle() }); const global_actions = comptime std.enums.values(keybinds.Action); for (global_actions) |action| { @@ -2001,8 +2001,9 @@ pub const App = struct { // 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}", .{trimmed_label}), + .text = try std.fmt.allocPrint(arena, " Active Tab: {s}", .{without_number}), .style = th.headerStyle(), }); diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index c37379e..313c045 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -151,6 +151,7 @@ pub const tab = struct { }); pub const status_hints: []const Action = &.{ + .sort_col_prev, .sort_col_next, .sort_reverse, .open_account_picker, @@ -1507,34 +1508,103 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va } fn drawWelcomeScreen(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { - const th = app.theme; - const welcome_lines = [_]StyledLine{ - .{ .text = "", .style = th.contentStyle() }, - .{ .text = " zfin", .style = th.headerStyle() }, - .{ .text = "", .style = th.contentStyle() }, - .{ .text = " No portfolio loaded.", .style = th.mutedStyle() }, - .{ .text = "", .style = th.contentStyle() }, - .{ .text = " Getting started:", .style = th.contentStyle() }, - .{ .text = " / Enter a stock symbol (e.g. AAPL, VTI)", .style = th.contentStyle() }, - .{ .text = "", .style = th.contentStyle() }, - .{ .text = " Portfolio mode:", .style = th.contentStyle() }, - .{ .text = " zfin -p portfolio.srf Load a portfolio file", .style = th.mutedStyle() }, - .{ .text = " portfolio.srf Auto-loaded from cwd if present", .style = th.mutedStyle() }, - .{ .text = "", .style = th.contentStyle() }, - .{ .text = " Navigation:", .style = th.contentStyle() }, - .{ .text = " h / l Previous / next tab", .style = th.mutedStyle() }, - .{ .text = " j / k Select next / prev item", .style = th.mutedStyle() }, - .{ .text = " Enter Expand position lots", .style = th.mutedStyle() }, - .{ .text = " s Select symbol for other tabs", .style = th.mutedStyle() }, - .{ .text = " 1-5 Jump to tab", .style = th.mutedStyle() }, - .{ .text = " ? Full help", .style = th.mutedStyle() }, - .{ .text = " q Quit", .style = th.mutedStyle() }, - .{ .text = "", .style = th.contentStyle() }, - .{ .text = " Sample portfolio.srf:", .style = th.contentStyle() }, - .{ .text = " symbol::VTI,shares::100,open_date::2024-01-15,open_price::220.50", .style = th.mutedStyle() }, - .{ .text = " symbol::AAPL,shares::50,open_date::2024-03-01,open_price::170.00", .style = th.mutedStyle() }, + // Resolve key bindings dynamically so the welcome screen reflects + // the user's actual keymap (defaults or overridden via keys.srf). + // Each `keysForGlobal` returns at least one key — global default + // bindings always exist (verified by the comptime conflict + // validator + tests). + const keys: WelcomeKeys = .{ + .symbol_input = (try app.keysForGlobal(arena, .symbol_input))[0], + .select_next = (try app.keysForGlobal(arena, .select_next))[0], + .select_prev = (try app.keysForGlobal(arena, .select_prev))[0], + .prev_tab = (try app.keysForGlobal(arena, .prev_tab))[0], + .next_tab = (try app.keysForGlobal(arena, .next_tab))[0], + .help = (try app.keysForGlobal(arena, .help))[0], + .quit = (try app.keysForGlobal(arena, .quit))[0], + .tab_1 = (try app.keysForGlobal(arena, .tab_1))[0], + .tab_5 = (try app.keysForGlobal(arena, .tab_5))[0], + .expand_collapse = (try app.keysForTabAction(arena, "portfolio", "expand_collapse"))[0], + .select_symbol = (try app.keysForTabAction(arena, "portfolio", "select_symbol"))[0], }; - try app.drawStyledContent(arena, buf, width, height, &welcome_lines); + const lines = try buildWelcomeScreenLines(arena, app.theme, keys); + try app.drawStyledContent(arena, buf, width, height, lines); +} + +/// Pre-resolved key bindings used by `buildWelcomeScreenLines`. All +/// fields are formatted key strings (e.g. `"j"`, `"ctrl+f"`) sourced +/// from the live keymap; the renderer doesn't know about the keymap. +pub const WelcomeKeys = struct { + symbol_input: []const u8, + select_next: []const u8, + select_prev: []const u8, + prev_tab: []const u8, + next_tab: []const u8, + help: []const u8, + quit: []const u8, + tab_1: []const u8, + tab_5: []const u8, + expand_collapse: []const u8, + select_symbol: []const u8, +}; + +/// Build the styled lines for the welcome screen shown when no +/// portfolio is loaded. Pure function over (arena, theme, keys); +/// no App access. Easy to unit-test by passing fixture keys. +pub fn buildWelcomeScreenLines( + arena: std.mem.Allocator, + th: theme.Theme, + keys: WelcomeKeys, +) ![]const StyledLine { + var lines: std.ArrayListUnmanaged(StyledLine) = .empty; + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ .text = " zfin", .style = th.headerStyle() }); + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ .text = " No portfolio loaded.", .style = th.mutedStyle() }); + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ .text = " Getting started:", .style = th.contentStyle() }); + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " {s:<10} Enter a stock symbol (e.g. AAPL, VTI)", .{keys.symbol_input}), + .style = th.contentStyle(), + }); + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ .text = " Portfolio mode:", .style = th.contentStyle() }); + try lines.append(arena, .{ .text = " zfin -p portfolio.srf Load a portfolio file", .style = th.mutedStyle() }); + try lines.append(arena, .{ .text = " portfolio.srf Auto-loaded from cwd if present", .style = th.mutedStyle() }); + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ .text = " Navigation:", .style = th.contentStyle() }); + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " {s} / {s} Previous / next tab", .{ keys.prev_tab, keys.next_tab }), + .style = th.mutedStyle(), + }); + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " {s} / {s} Select next / prev item", .{ keys.select_next, keys.select_prev }), + .style = th.mutedStyle(), + }); + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " {s:<10} Expand position lots", .{keys.expand_collapse}), + .style = th.mutedStyle(), + }); + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " {s:<10} Select symbol for other tabs", .{keys.select_symbol}), + .style = th.mutedStyle(), + }); + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " {s}-{s} Jump to tab", .{ keys.tab_1, keys.tab_5 }), + .style = th.mutedStyle(), + }); + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " {s:<10} Full help", .{keys.help}), + .style = th.mutedStyle(), + }); + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " {s:<10} Quit", .{keys.quit}), + .style = th.mutedStyle(), + }); + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ .text = " Sample portfolio.srf:", .style = th.contentStyle() }); + try lines.append(arena, .{ .text = " symbol::VTI,shares::100,open_date::2024-01-15,open_price::220.50", .style = th.mutedStyle() }); + try lines.append(arena, .{ .text = " symbol::AAPL,shares::50,open_date::2024-03-01,open_price::170.00", .style = th.mutedStyle() }); + return lines.toOwnedSlice(arena); } /// Reload portfolio file from disk without re-fetching prices. @@ -1779,3 +1849,103 @@ pub fn drawAccountPicker(state: *State, app: *App, arena: std.mem.Allocator, buf try app.drawStyledContent(arena, buf, width, height, lines.items[start..]); } + +// ── Tests ───────────────────────────────────────────────────── + +const testing = std.testing; + +test "buildWelcomeScreenLines: includes resolved keys in expected slots" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const keys: WelcomeKeys = .{ + .symbol_input = "/", + .select_next = "j", + .select_prev = "k", + .prev_tab = "h", + .next_tab = "l", + .help = "?", + .quit = "q", + .tab_1 = "1", + .tab_5 = "5", + .expand_collapse = "enter", + .select_symbol = "s", + }; + + const lines = try buildWelcomeScreenLines(arena, theme.default_theme, keys); + + // Concatenate all line text for substring assertions. + var all: std.ArrayListUnmanaged(u8) = .empty; + for (lines) |l| { + try all.appendSlice(arena, l.text); + try all.append(arena, '\n'); + } + const text = all.items; + + // Header + body sections present. + try testing.expect(std.mem.indexOf(u8, text, "zfin") != null); + try testing.expect(std.mem.indexOf(u8, text, "No portfolio loaded.") != null); + try testing.expect(std.mem.indexOf(u8, text, "Getting started:") != null); + try testing.expect(std.mem.indexOf(u8, text, "Portfolio mode:") != null); + try testing.expect(std.mem.indexOf(u8, text, "Navigation:") != null); + try testing.expect(std.mem.indexOf(u8, text, "Sample portfolio.srf:") != null); + + // All resolved keys appear in their respective rows. Format + // strings use `{s:<10}` (ten-char field) and ` ` (two-space) + // gap before the description, so the gap between key and + // description = (10 - keylen) padding + 2 separator. + try testing.expect(std.mem.indexOf(u8, text, "/ Enter a stock symbol") != null); + try testing.expect(std.mem.indexOf(u8, text, "h / l Previous / next tab") != null); + try testing.expect(std.mem.indexOf(u8, text, "j / k Select next / prev item") != null); + try testing.expect(std.mem.indexOf(u8, text, "enter Expand position lots") != null); + try testing.expect(std.mem.indexOf(u8, text, "s Select symbol for other tabs") != null); + try testing.expect(std.mem.indexOf(u8, text, "1-5 Jump to tab") != null); + try testing.expect(std.mem.indexOf(u8, text, "? Full help") != null); + try testing.expect(std.mem.indexOf(u8, text, "q Quit") != null); +} + +test "buildWelcomeScreenLines: respects rebound keys" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // User-rebound keys: arbitrary substitutions. + const keys: WelcomeKeys = .{ + .symbol_input = "ctrl+s", + .select_next = "down", + .select_prev = "up", + .prev_tab = "shift+tab", + .next_tab = "tab", + .help = "F1", + .quit = "ctrl+q", + .tab_1 = "f1", + .tab_5 = "f5", + .expand_collapse = "space", + .select_symbol = "x", + }; + + const lines = try buildWelcomeScreenLines(arena, theme.default_theme, keys); + + var all: std.ArrayListUnmanaged(u8) = .empty; + for (lines) |l| { + try all.appendSlice(arena, l.text); + try all.append(arena, '\n'); + } + const text = all.items; + + // Verify every rebound key is rendered. + try testing.expect(std.mem.indexOf(u8, text, "ctrl+s") != null); + try testing.expect(std.mem.indexOf(u8, text, "shift+tab / tab") != null); + try testing.expect(std.mem.indexOf(u8, text, "down / up") != null); + try testing.expect(std.mem.indexOf(u8, text, "space") != null); + try testing.expect(std.mem.indexOf(u8, text, "x Select symbol") != null); + try testing.expect(std.mem.indexOf(u8, text, "f1-f5") != null); + try testing.expect(std.mem.indexOf(u8, text, "F1 Full help") != null); + try testing.expect(std.mem.indexOf(u8, text, "ctrl+q Quit") != null); + + // No default keys leaked through (sanity). + try testing.expect(std.mem.indexOf(u8, text, " / Enter a stock symbol") == null); + try testing.expect(std.mem.indexOf(u8, text, "h / l") == null); + try testing.expect(std.mem.indexOf(u8, text, "j / k") == null); +}