better account filter for interactive mode

This commit is contained in:
Emil Lerch 2026-04-10 09:39:26 -07:00
parent c3c9c21556
commit ddd47dad66
Signed by: lobo
GPG key ID: A7B62D657EF764F8
3 changed files with 298 additions and 38 deletions

View file

@ -95,6 +95,7 @@ pub const InputMode = enum {
symbol_input,
help,
account_picker,
account_search,
};
pub const StyledLine = struct {
@ -310,8 +311,14 @@ pub const App = struct {
// Account filter state
account_filter: ?[]const u8 = null, // active account filter (owned copy; null = all accounts)
filtered_positions: ?[]zfin.Position = null, // positions for filtered account (from positionsForAccount)
account_list: std.ArrayList([]const u8) = .empty, // distinct accounts from portfolio lots (borrowed from portfolio)
account_list: std.ArrayList([]const u8) = .empty, // distinct accounts from portfolio lots (ordered by accounts.srf)
account_numbers: std.ArrayList(?[]const u8) = .empty, // account_number from accounts.srf (parallel to account_list)
account_shortcut_keys: std.ArrayList(u8) = .empty, // auto-assigned shortcut key per account (parallel to account_list)
account_picker_cursor: usize = 0, // cursor position in picker (0 = "All accounts")
account_search_buf: [64]u8 = undefined,
account_search_len: usize = 0,
account_search_matches: std.ArrayList(usize) = .empty, // indices into account_list matching search
account_search_cursor: usize = 0, // cursor within search_matches
// Options navigation (inline expand/collapse like portfolio)
options_cursor: usize = 0, // selected row in flattened options view
@ -388,6 +395,9 @@ pub const App = struct {
if (self.mode == .account_picker) {
return self.handleAccountPickerKey(ctx, key);
}
if (self.mode == .account_search) {
return self.handleAccountSearchKey(ctx, key);
}
if (self.mode == .help) {
self.mode = .normal;
return ctx.consumeAndRedraw();
@ -620,19 +630,44 @@ pub const App = struct {
fn handleAccountPickerKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void {
const total_items = self.account_list.items.len + 1; // +1 for "All accounts"
// Cancel: return to normal mode without changing the filter
if (key.codepoint == vaxis.Key.escape) {
if (key.codepoint == vaxis.Key.escape or key.codepoint == 'q') {
self.mode = .normal;
return ctx.consumeAndRedraw();
}
// Confirm: apply the selected account filter
if (key.codepoint == vaxis.Key.enter) {
self.applyAccountPickerSelection();
return ctx.consumeAndRedraw();
}
// Use the keymap for navigation actions
// '/' enters search mode
if (key.matches('/', .{})) {
self.mode = .account_search;
self.account_search_len = 0;
self.updateAccountSearchMatches();
return ctx.consumeAndRedraw();
}
// 'A' selects "All accounts" instantly
if (key.matches('A', .{})) {
self.account_picker_cursor = 0;
self.applyAccountPickerSelection();
return ctx.consumeAndRedraw();
}
// Check shortcut keys for instant selection
if (key.codepoint < std.math.maxInt(u7) and key.matches(key.codepoint, .{})) {
const ch: u8 = @intCast(key.codepoint);
for (self.account_shortcut_keys.items, 0..) |shortcut, i| {
if (shortcut == ch) {
self.account_picker_cursor = i + 1; // +1 for "All accounts" at 0
self.applyAccountPickerSelection();
return ctx.consumeAndRedraw();
}
}
}
// Navigation via keymap
const action = self.keymap.matchAction(key) orelse return;
switch (action) {
.select_next => {
@ -658,6 +693,111 @@ pub const App = struct {
}
}
/// Handles keypresses in account_search mode (/ search within picker).
fn handleAccountSearchKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void {
// Escape: cancel search, return to picker
if (key.codepoint == vaxis.Key.escape) {
self.mode = .account_picker;
self.account_search_len = 0;
return ctx.consumeAndRedraw();
}
// Enter: select the first match (or current search cursor)
if (key.codepoint == vaxis.Key.enter) {
if (self.account_search_matches.items.len > 0) {
const match_idx = self.account_search_matches.items[self.account_search_cursor];
self.account_picker_cursor = match_idx + 1; // +1 for "All accounts"
}
self.account_search_len = 0;
self.applyAccountPickerSelection();
return ctx.consumeAndRedraw();
}
// Ctrl+N / Ctrl+P or arrow keys to cycle through matches
if (key.matches('n', .{ .ctrl = true }) or key.codepoint == vaxis.Key.down) {
if (self.account_search_matches.items.len > 0 and
self.account_search_cursor < self.account_search_matches.items.len - 1)
self.account_search_cursor += 1;
return ctx.consumeAndRedraw();
}
if (key.matches('p', .{ .ctrl = true }) or key.codepoint == vaxis.Key.up) {
if (self.account_search_cursor > 0)
self.account_search_cursor -= 1;
return ctx.consumeAndRedraw();
}
// Backspace
if (key.codepoint == vaxis.Key.backspace) {
if (self.account_search_len > 0) {
self.account_search_len -= 1;
self.updateAccountSearchMatches();
}
return ctx.consumeAndRedraw();
}
// Ctrl+U: clear search
if (key.matches('u', .{ .ctrl = true })) {
self.account_search_len = 0;
self.updateAccountSearchMatches();
return ctx.consumeAndRedraw();
}
// Printable ASCII
if (key.codepoint < std.math.maxInt(u7) and std.ascii.isPrint(@intCast(key.codepoint)) and self.account_search_len < self.account_search_buf.len) {
self.account_search_buf[self.account_search_len] = @intCast(key.codepoint);
self.account_search_len += 1;
self.updateAccountSearchMatches();
return ctx.consumeAndRedraw();
}
}
/// Update search match indices based on current search string.
fn updateAccountSearchMatches(self: *App) void {
self.account_search_matches.clearRetainingCapacity();
const query = self.account_search_buf[0..self.account_search_len];
if (query.len == 0) return;
var lower_query: [64]u8 = undefined;
for (query, 0..) |c, i| lower_query[i] = std.ascii.toLower(c);
const lq = lower_query[0..query.len];
for (self.account_list.items, 0..) |acct, i| {
if (containsLower(acct, lq)) {
self.account_search_matches.append(self.allocator, i) catch continue;
} else if (i < self.account_numbers.items.len) {
if (self.account_numbers.items[i]) |num| {
if (containsLower(num, lq)) {
self.account_search_matches.append(self.allocator, i) catch continue;
}
}
}
}
if (self.account_search_cursor >= self.account_search_matches.items.len) {
self.account_search_cursor = if (self.account_search_matches.items.len > 0)
self.account_search_matches.items.len - 1
else
0;
}
}
fn containsLower(haystack: []const u8, needle_lower: []const u8) bool {
if (needle_lower.len == 0) return true;
if (haystack.len < needle_lower.len) return false;
const end = haystack.len - needle_lower.len + 1;
for (0..end) |start| {
var matched = true;
for (0..needle_lower.len) |j| {
if (std.ascii.toLower(haystack[start + j]) != needle_lower[j]) {
matched = false;
break;
}
}
if (matched) return true;
}
return false;
}
/// Apply the current account picker selection and return to normal mode.
fn applyAccountPickerSelection(self: *App) void {
if (self.account_picker_cursor == 0) {
@ -683,6 +823,20 @@ pub const App = struct {
}
}
/// Load accounts.srf if not already loaded. Derives path from portfolio_path.
pub fn ensureAccountMap(self: *App) void {
if (self.account_map != null) return;
const ppath = self.portfolio_path orelse return;
const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, std.fs.path.sep)) |idx| idx + 1 else 0;
const acct_path = std.fmt.allocPrint(self.allocator, "{s}accounts.srf", .{ppath[0..dir_end]}) catch return;
defer self.allocator.free(acct_path);
if (std.fs.cwd().readFileAlloc(self.allocator, acct_path, 1024 * 1024)) |acct_data| {
defer self.allocator.free(acct_data);
self.account_map = zfin.analysis.parseAccountsFile(self.allocator, acct_data) catch null;
} else |_| {}
}
/// Set or clear the account filter. Owns the string via allocator.
pub fn setAccountFilter(self: *App, name: ?[]const u8) void {
if (self.account_filter) |old| self.allocator.free(old);
@ -1352,6 +1506,9 @@ pub const App = struct {
self.portfolio_rows.deinit(self.allocator);
self.options_rows.deinit(self.allocator);
self.account_list.deinit(self.allocator);
self.account_numbers.deinit(self.allocator);
self.account_shortcut_keys.deinit(self.allocator);
self.account_search_matches.deinit(self.allocator);
if (self.account_filter) |af| self.allocator.free(af);
if (self.filtered_positions) |fp| self.allocator.free(fp);
if (self.watchlist_prices) |*wp| wp.deinit();
@ -1455,7 +1612,7 @@ pub const App = struct {
if (self.mode == .help) {
try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildHelpStyledLines(ctx.arena));
} else if (self.mode == .account_picker) {
} else if (self.mode == .account_picker or self.mode == .account_search) {
try portfolio_tab.drawAccountPicker(self, ctx.arena, buf, width, height);
} else {
switch (self.active_tab) {

View file

@ -40,23 +40,7 @@ pub fn loadData(app: *App) void {
}
// Load account tax type metadata file (optional)
if (app.account_map == null) {
if (app.portfolio_path) |ppath| {
const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, std.fs.path.sep)) |idx| idx + 1 else 0;
const acct_path = std.fmt.allocPrint(app.allocator, "{s}accounts.srf", .{ppath[0..dir_end]}) catch {
loadDataFinish(app, pf, summary);
return;
};
defer app.allocator.free(acct_path);
if (std.fs.cwd().readFileAlloc(app.allocator, acct_path, 1024 * 1024)) |acct_data| {
defer app.allocator.free(acct_data);
app.account_map = zfin.analysis.parseAccountsFile(app.allocator, acct_data) catch null;
} else |_| {
// accounts.srf is optional -- analysis works without it
}
}
}
app.ensureAccountMap();
loadDataFinish(app, pf, summary);
}

View file

@ -548,33 +548,73 @@ pub fn rebuildPortfolioRows(app: *App) void {
}
}
/// Build the sorted list of distinct account names from portfolio lots.
/// Called after portfolio data is loaded or reloaded.
/// Build the ordered list of distinct account names from portfolio lots.
/// Order: accounts.srf file order first, then any remaining accounts alphabetically.
/// Also assigns shortcut keys and loads account numbers from accounts.srf.
pub fn buildAccountList(app: *App) void {
app.account_list.clearRetainingCapacity();
app.account_numbers.clearRetainingCapacity();
app.account_shortcut_keys.clearRetainingCapacity();
const pf = app.portfolio orelse return;
// Use a set to deduplicate
// Collect distinct account names from portfolio lots
var seen = std.StringHashMap(void).init(app.allocator);
defer seen.deinit();
var lot_accounts = std.ArrayList([]const u8).empty;
defer lot_accounts.deinit(app.allocator);
for (pf.lots) |lot| {
if (lot.account) |acct| {
if (acct.len > 0 and !seen.contains(acct)) {
seen.put(acct, {}) catch continue;
app.account_list.append(app.allocator, acct) catch continue;
lot_accounts.append(app.allocator, acct) catch continue;
}
}
}
// Sort alphabetically
std.mem.sort([]const u8, app.account_list.items, {}, struct {
app.ensureAccountMap();
// Phase 1: add accounts in accounts.srf order (if available)
if (app.account_map) |am| {
for (am.entries) |entry| {
if (seen.contains(entry.account)) {
app.account_list.append(app.allocator, entry.account) catch continue;
app.account_numbers.append(app.allocator, entry.account_number) catch continue;
}
}
}
// Phase 2: add accounts not in accounts.srf, sorted alphabetically
var extras = std.ArrayList([]const u8).empty;
defer extras.deinit(app.allocator);
for (lot_accounts.items) |acct| {
var found = false;
for (app.account_list.items) |existing| {
if (std.mem.eql(u8, acct, existing)) {
found = true;
break;
}
}
if (!found) extras.append(app.allocator, acct) catch continue;
}
std.mem.sort([]const u8, extras.items, {}, struct {
fn lessThan(_: void, a: []const u8, b: []const u8) bool {
return std.mem.lessThan(u8, a, b);
}
}.lessThan);
for (extras.items) |acct| {
app.account_list.append(app.allocator, acct) catch continue;
app.account_numbers.append(app.allocator, null) catch continue;
}
// Assign shortcut keys: 1-9, 0, then b-z (skipping conflict keys)
assignShortcutKeys(app);
// If the current filter no longer exists in the new list, clear it
if (app.account_filter) |af| {
var found = false;
@ -588,6 +628,21 @@ pub fn buildAccountList(app: *App) void {
}
}
const shortcut_key_order = "1234567890bcdefhimnoptuvwxyz";
fn assignShortcutKeys(app: *App) void {
app.account_shortcut_keys.clearRetainingCapacity();
var key_idx: usize = 0;
for (0..app.account_list.items.len) |_| {
if (key_idx < shortcut_key_order.len) {
app.account_shortcut_keys.append(app.allocator, shortcut_key_order[key_idx]) catch continue;
key_idx += 1;
} else {
app.account_shortcut_keys.append(app.allocator, 0) catch continue;
}
}
}
/// Recompute filtered_positions when portfolio or account filter changes.
fn recomputeFilteredPositions(app: *App) void {
if (app.filtered_positions) |fp| app.allocator.free(fp);
@ -1265,28 +1320,92 @@ pub fn drawAccountPicker(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell,
const th = app.theme;
var lines: std.ArrayList(tui.StyledLine) = .empty;
const is_searching = app.mode == .account_search;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " Filter by Account", .style = th.headerStyle() });
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// Build a set of search-highlighted indices for fast lookup
var search_highlight = std.AutoHashMap(usize, void).init(arena);
var search_cursor_idx: ?usize = null;
if (is_searching and app.account_search_matches.items.len > 0) {
for (app.account_search_matches.items, 0..) |match_idx, si| {
search_highlight.put(match_idx, {}) catch {};
if (si == app.account_search_cursor) search_cursor_idx = match_idx;
}
}
// Item 0 = "All accounts" (clears filter)
const total_items = app.account_list.items.len + 1;
for (0..total_items) |i| {
const is_selected = i == app.account_picker_cursor;
const marker: []const u8 = if (is_selected) " > " else " ";
const label: []const u8 = if (i == 0) "All accounts" else app.account_list.items[i - 1];
const text = try std.fmt.allocPrint(arena, "{s}{s}", .{ marker, label });
const style = if (is_selected) th.selectStyle() else th.contentStyle();
try lines.append(arena, .{ .text = text, .style = style });
const is_selected = if (is_searching)
(if (search_cursor_idx) |sci| i == sci + 1 else false)
else
i == app.account_picker_cursor;
const marker: []const u8 = if (is_selected) " > " else " ";
if (i == 0) {
const text = try std.fmt.allocPrint(arena, "{s}A: All accounts", .{marker});
const style = if (is_selected) th.selectStyle() else th.contentStyle();
const dimmed = is_searching and app.account_search_len > 0;
try lines.append(arena, .{ .text = text, .style = if (dimmed) th.mutedStyle() else style });
} else {
const acct_idx = i - 1;
const label = app.account_list.items[acct_idx];
const shortcut: u8 = if (acct_idx < app.account_shortcut_keys.items.len) app.account_shortcut_keys.items[acct_idx] else 0;
const acct_num: ?[]const u8 = if (acct_idx < app.account_numbers.items.len) app.account_numbers.items[acct_idx] else null;
const text = if (acct_num) |num|
(if (shortcut != 0)
try std.fmt.allocPrint(arena, "{s}{c}: {s} ({s})", .{ marker, shortcut, label, num })
else
try std.fmt.allocPrint(arena, "{s} {s} ({s})", .{ marker, label, num }))
else if (shortcut != 0)
try std.fmt.allocPrint(arena, "{s}{c}: {s}", .{ marker, shortcut, label })
else
try std.fmt.allocPrint(arena, "{s} {s}", .{ marker, label });
var style = if (is_selected) th.selectStyle() else th.contentStyle();
if (is_searching and app.account_search_len > 0) {
if (search_highlight.contains(acct_idx)) {
if (!is_selected) style = th.headerStyle();
} else {
style = th.mutedStyle();
}
}
try lines.append(arena, .{ .text = text, .style = style });
}
}
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// Search prompt at the bottom
if (is_searching) {
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
const query = app.account_search_buf[0..app.account_search_len];
const match_count = app.account_search_matches.items.len;
const prompt = if (query.len > 0)
try std.fmt.allocPrint(arena, " /{s} ({d} match{s})", .{
query,
match_count,
if (match_count != 1) @as([]const u8, "es") else "",
})
else
try std.fmt.allocPrint(arena, " /", .{});
try lines.append(arena, .{ .text = prompt, .style = th.headerStyle() });
} else {
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " /: search j/k: navigate Enter: select Esc: cancel", .style = th.mutedStyle() });
}
// Scroll so cursor is visible
const cursor_line = app.account_picker_cursor + account_picker_header_lines;
const effective_cursor = if (is_searching)
(if (search_cursor_idx) |sci| sci + 1 else 0)
else
app.account_picker_cursor;
const cursor_line = effective_cursor + account_picker_header_lines;
var start: usize = 0;
if (cursor_line >= height) {
start = cursor_line - height + 2; // keep one line of padding below
start = cursor_line - height + 2;
}
start = @min(start, if (lines.items.len > 0) lines.items.len - 1 else 0);