portfolio tab help cleanup

This commit is contained in:
Emil Lerch 2026-05-15 13:40:24 -07:00
parent db70b1f924
commit ca6683feef
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 200 additions and 29 deletions

View file

@ -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(),
});

View file

@ -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);
}