diff --git a/src/tui.zig b/src/tui.zig index c09d163..908c51b 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -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) { diff --git a/src/tui/analysis_tab.zig b/src/tui/analysis_tab.zig index e4dce32..90f1b57 100644 --- a/src/tui/analysis_tab.zig +++ b/src/tui/analysis_tab.zig @@ -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); } diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index 9c0af56..1b20393 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -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);