Compare commits
No commits in common. "5ee2151a4737dffc420616d028cd10b2c36f1600" and "3ae53f15b000aa48eb7aa3336b235706857d53b4" have entirely different histories.
5ee2151a47
...
3ae53f15b0
10 changed files with 120 additions and 2067 deletions
24
TODO.md
24
TODO.md
|
|
@ -52,30 +52,6 @@ previously selected one (like `cd -` in bash or `Ctrl+^` in vim). Store
|
|||
key swaps current and last. Works on any tab — particularly useful for
|
||||
eyeball-comparing performance/risk data between two symbols.
|
||||
|
||||
## Fix `enrich` command for international funds
|
||||
|
||||
`deriveMetadata` in `src/commands/enrich.zig` misclassifies international ETFs:
|
||||
|
||||
1. **`geo`** uses Alpha Vantage's `Country` field, which is the *fund issuer's*
|
||||
domicile (USA for all US-listed ETFs), not the fund's investment geography.
|
||||
Every US-domiciled international fund gets `geo::US`.
|
||||
|
||||
2. **`asset_class`** short-circuits to `"ETF"` when `asset_type == "ETF"`, or
|
||||
falls through to a US-market-cap heuristic that always produces
|
||||
`"US Large Cap"` / `"US Mid Cap"` / `"US Small Cap"`.
|
||||
|
||||
Known misclassified tickers (all came back as `geo::US, asset_class::US Large Cap`):
|
||||
- **FRDM** — Freedom 100 Emerging Markets ETF → should be `geo::Emerging Markets, asset_class::Emerging Markets`
|
||||
- **HFXI** — NYLI FTSE International Equity Currency Neutral ETF → should be `geo::International Developed, asset_class::International Developed`
|
||||
- **IDMO** — Invesco S&P International Developed Momentum ETF → should be `geo::International Developed, asset_class::International Developed`
|
||||
- **IVLU** — iShares MSCI International Developed Value Factor ETF → should be `geo::International Developed, asset_class::International Developed`
|
||||
|
||||
The Alpha Vantage OVERVIEW endpoint doesn't provide fund geography data.
|
||||
Options: use the ETF_PROFILE holdings/country data to infer geography, parse
|
||||
the fund name for keywords ("International", "Emerging", "ex-US"), or accept
|
||||
that `enrich` is a scaffold and emit a `# TODO` comment for ETFs instead of
|
||||
silently misclassifying.
|
||||
|
||||
## Market-aware cache TTL for daily candles
|
||||
|
||||
Daily candle TTL is currently 23h45m, but candle data only becomes meaningful
|
||||
|
|
|
|||
13
metadata.srf
13
metadata.srf
|
|
@ -110,16 +110,3 @@ symbol::ORC42,asset_class::Bonds,geo::US,pct:num:8.33
|
|||
symbol::VFORX,geo::US,asset_class::US Large Cap
|
||||
|
||||
symbol::VTTHX,geo::US,asset_class::US Large Cap
|
||||
|
||||
# ── International ETFs ───────────────────────────────────────
|
||||
# Freedom 100 Emerging Markets ETF
|
||||
symbol::FRDM,sector::Diversified,geo::Emerging Markets,asset_class::Emerging Markets
|
||||
|
||||
# NYLI FTSE International Equity Currency Neutral ETF (50% hedged)
|
||||
symbol::HFXI,sector::Diversified,geo::International Developed,asset_class::International Developed
|
||||
|
||||
# Invesco S&P International Developed Momentum ETF
|
||||
symbol::IDMO,sector::Diversified,geo::International Developed,asset_class::International Developed
|
||||
|
||||
# iShares MSCI International Developed Value Factor ETF
|
||||
symbol::IVLU,sector::Diversified,geo::International Developed,asset_class::International Developed
|
||||
|
|
|
|||
|
|
@ -38,8 +38,6 @@ pub const TaxType = enum {
|
|||
pub const AccountTaxEntry = struct {
|
||||
account: []const u8,
|
||||
tax_type: TaxType,
|
||||
institution: ?[]const u8 = null,
|
||||
account_number: ?[]const u8 = null,
|
||||
};
|
||||
|
||||
/// Parsed account metadata.
|
||||
|
|
@ -50,8 +48,6 @@ pub const AccountMap = struct {
|
|||
pub fn deinit(self: *AccountMap) void {
|
||||
for (self.entries) |e| {
|
||||
self.allocator.free(e.account);
|
||||
if (e.institution) |s| self.allocator.free(s);
|
||||
if (e.account_number) |s| self.allocator.free(s);
|
||||
}
|
||||
self.allocator.free(self.entries);
|
||||
}
|
||||
|
|
@ -65,42 +61,15 @@ pub const AccountMap = struct {
|
|||
}
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
/// Find the portfolio account name for a given institution + account number.
|
||||
pub fn findByInstitutionAccount(self: AccountMap, institution: []const u8, account_number: []const u8) ?[]const u8 {
|
||||
for (self.entries) |e| {
|
||||
if (e.institution) |inst| {
|
||||
if (e.account_number) |num| {
|
||||
if (std.mem.eql(u8, inst, institution) and std.mem.eql(u8, num, account_number))
|
||||
return e.account;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Return all entries matching a given institution.
|
||||
pub fn entriesForInstitution(self: AccountMap, institution: []const u8) []const AccountTaxEntry {
|
||||
var count: usize = 0;
|
||||
for (self.entries) |e| {
|
||||
if (e.institution) |inst| {
|
||||
if (std.mem.eql(u8, inst, institution)) count += 1;
|
||||
}
|
||||
}
|
||||
if (count == 0) return &.{};
|
||||
return self.entries;
|
||||
}
|
||||
};
|
||||
|
||||
/// Parse an accounts.srf file into an AccountMap.
|
||||
/// Each record has: account::<NAME>,tax_type::<TYPE>[,institution::<INST>][,account_number::<NUM>]
|
||||
/// Each record has: account::<NAME>,tax_type::<TYPE>
|
||||
pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !AccountMap {
|
||||
var entries = std.ArrayList(AccountTaxEntry).empty;
|
||||
errdefer {
|
||||
for (entries.items) |e| {
|
||||
allocator.free(e.account);
|
||||
if (e.institution) |s| allocator.free(s);
|
||||
if (e.account_number) |s| allocator.free(s);
|
||||
}
|
||||
entries.deinit(allocator);
|
||||
}
|
||||
|
|
@ -114,8 +83,6 @@ pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !Accoun
|
|||
try entries.append(allocator, .{
|
||||
.account = try allocator.dupe(u8, entry.account),
|
||||
.tax_type = entry.tax_type,
|
||||
.institution = if (entry.institution) |s| try allocator.dupe(u8, s) else null,
|
||||
.account_number = if (entry.account_number) |s| try allocator.dupe(u8, s) else null,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []co
|
|||
defer pf_data.deinit(allocator);
|
||||
|
||||
// Load classification metadata
|
||||
const dir_end = if (std.mem.lastIndexOfScalar(u8, file_path, std.fs.path.sep)) |idx| idx + 1 else 0;
|
||||
const dir_end = if (std.mem.lastIndexOfScalar(u8, file_path, '/')) |idx| idx + 1 else 0;
|
||||
const meta_path = std.fmt.allocPrint(allocator, "{s}metadata.srf", .{file_path[0..dir_end]}) catch return;
|
||||
defer allocator.free(meta_path);
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
11
src/main.zig
11
src/main.zig
|
|
@ -20,7 +20,6 @@ const usage =
|
|||
\\ analysis [FILE] Show portfolio analysis (default: portfolio.srf)
|
||||
\\ enrich <FILE|SYMBOL> Bootstrap metadata.srf from Alpha Vantage (25 req/day limit)
|
||||
\\ lookup <CUSIP> Look up CUSIP to ticker via OpenFIGI
|
||||
\\ audit [opts] Reconcile portfolio against brokerage export
|
||||
\\ cache stats Show cache statistics
|
||||
\\ cache clear Clear all cached data
|
||||
\\
|
||||
|
|
@ -43,12 +42,6 @@ const usage =
|
|||
\\ -w, --watchlist <FILE> Watchlist file
|
||||
\\ --refresh Force refresh (ignore cache, re-fetch all prices)
|
||||
\\
|
||||
\\Audit command options:
|
||||
\\ --fidelity <CSV> Fidelity positions CSV export (download from "All accounts" positions tab)
|
||||
\\ --schwab <CSV> Schwab per-account positions CSV export
|
||||
\\ --schwab-summary Schwab account summary (copy from accounts summary page, paste to stdin)
|
||||
\\ -p, --portfolio <FILE> Portfolio file (default: portfolio.srf)
|
||||
\\
|
||||
\\Analysis command:
|
||||
\\ Reads metadata.srf (classification) and accounts.srf (tax types)
|
||||
\\ from the same directory as the portfolio file.
|
||||
|
|
@ -115,7 +108,6 @@ pub fn main() !u8 {
|
|||
if (args.len >= 3 and
|
||||
!std.mem.eql(u8, command, "cache") and
|
||||
!std.mem.eql(u8, command, "enrich") and
|
||||
!std.mem.eql(u8, command, "audit") and
|
||||
!std.mem.eql(u8, command, "analysis") and
|
||||
!std.mem.eql(u8, command, "portfolio"))
|
||||
{
|
||||
|
|
@ -222,8 +214,6 @@ pub fn main() !u8 {
|
|||
return 1;
|
||||
}
|
||||
try commands.enrich.run(allocator, &svc, args[2], out);
|
||||
} else if (std.mem.eql(u8, command, "audit")) {
|
||||
try commands.audit.run(allocator, &svc, args[2..], color, out);
|
||||
} else if (std.mem.eql(u8, command, "analysis")) {
|
||||
// File path is first non-flag arg (default: portfolio.srf)
|
||||
var analysis_file: []const u8 = "portfolio.srf";
|
||||
|
|
@ -258,7 +248,6 @@ const commands = struct {
|
|||
const lookup = @import("commands/lookup.zig");
|
||||
const cache = @import("commands/cache.zig");
|
||||
const analysis = @import("commands/analysis.zig");
|
||||
const audit = @import("commands/audit.zig");
|
||||
const enrich = @import("commands/enrich.zig");
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -126,9 +126,6 @@ pub const Lot = struct {
|
|||
/// Aggregated position for a single symbol across multiple lots.
|
||||
pub const Position = struct {
|
||||
symbol: []const u8,
|
||||
/// Original lot symbol before ticker aliasing (e.g. CUSIP "02315N600").
|
||||
/// Same as `symbol` when no ticker alias is set.
|
||||
lot_symbol: []const u8 = "",
|
||||
/// Total open shares
|
||||
shares: f64,
|
||||
/// Weighted average cost basis per share (open lots only)
|
||||
|
|
@ -255,7 +252,6 @@ pub const Portfolio = struct {
|
|||
if (!entry.found_existing) {
|
||||
entry.value_ptr.* = .{
|
||||
.symbol = sym,
|
||||
.lot_symbol = lot.symbol,
|
||||
.shares = 0,
|
||||
.avg_cost = 0,
|
||||
.total_cost = 0,
|
||||
|
|
@ -306,78 +302,6 @@ pub const Portfolio = struct {
|
|||
return result.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
/// Aggregate stock/ETF lots into positions for a single account.
|
||||
/// Same logic as positions() but filtered to lots matching `account_name`.
|
||||
/// Only includes positions with at least one open lot (closed-only symbols are excluded).
|
||||
pub fn positionsForAccount(self: Portfolio, allocator: std.mem.Allocator, account_name: []const u8) ![]Position {
|
||||
var map = std.StringHashMap(Position).init(allocator);
|
||||
defer map.deinit();
|
||||
|
||||
for (self.lots) |lot| {
|
||||
if (lot.security_type != .stock) continue;
|
||||
const lot_acct = lot.account orelse continue;
|
||||
if (!std.mem.eql(u8, lot_acct, account_name)) continue;
|
||||
|
||||
const sym = lot.priceSymbol();
|
||||
const entry = try map.getOrPut(sym);
|
||||
if (!entry.found_existing) {
|
||||
entry.value_ptr.* = .{
|
||||
.symbol = sym,
|
||||
.lot_symbol = lot.symbol,
|
||||
.shares = 0,
|
||||
.avg_cost = 0,
|
||||
.total_cost = 0,
|
||||
.open_lots = 0,
|
||||
.closed_lots = 0,
|
||||
.realized_gain_loss = 0,
|
||||
.account = lot_acct,
|
||||
.note = lot.note,
|
||||
.price_ratio = lot.price_ratio,
|
||||
};
|
||||
} else {
|
||||
if (entry.value_ptr.price_ratio == 1.0 and lot.price_ratio != 1.0) {
|
||||
entry.value_ptr.price_ratio = lot.price_ratio;
|
||||
}
|
||||
}
|
||||
if (lot.isOpen()) {
|
||||
entry.value_ptr.shares += lot.shares;
|
||||
entry.value_ptr.total_cost += lot.costBasis();
|
||||
entry.value_ptr.open_lots += 1;
|
||||
} else {
|
||||
entry.value_ptr.closed_lots += 1;
|
||||
entry.value_ptr.realized_gain_loss += lot.realizedGainLoss() orelse 0;
|
||||
}
|
||||
}
|
||||
|
||||
var iter = map.valueIterator();
|
||||
while (iter.next()) |pos| {
|
||||
if (pos.shares > 0) {
|
||||
pos.avg_cost = pos.total_cost / pos.shares;
|
||||
}
|
||||
}
|
||||
|
||||
var result = std.ArrayList(Position).empty;
|
||||
errdefer result.deinit(allocator);
|
||||
|
||||
var viter = map.valueIterator();
|
||||
while (viter.next()) |pos| {
|
||||
if (pos.open_lots == 0) continue;
|
||||
try result.append(allocator, pos.*);
|
||||
}
|
||||
return result.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
/// Total cash for a single account.
|
||||
pub fn cashForAccount(self: Portfolio, account_name: []const u8) f64 {
|
||||
var total: f64 = 0;
|
||||
for (self.lots) |lot| {
|
||||
if (lot.security_type != .cash) continue;
|
||||
const lot_acct = lot.account orelse continue;
|
||||
if (std.mem.eql(u8, lot_acct, account_name)) total += lot.shares;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// Total cost basis of all open stock lots.
|
||||
pub fn totalCostBasis(self: Portfolio) f64 {
|
||||
var total: f64 = 0;
|
||||
|
|
@ -640,34 +564,3 @@ test "positions propagates price_ratio from lot" {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
test "positionsForAccount excludes closed-only symbols" {
|
||||
const allocator = std.testing.allocator;
|
||||
|
||||
var lots = [_]Lot{
|
||||
// Open lot in account A
|
||||
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
|
||||
// Closed lot in account A (was sold)
|
||||
.{ .symbol = "XLV", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 140.0, .close_date = Date.fromYmd(2025, 1, 1), .close_price = 150.0, .account = "Acct A" },
|
||||
// Open lot for same symbol in a different account
|
||||
.{ .symbol = "XLV", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 140.0, .account = "Acct B" },
|
||||
};
|
||||
|
||||
var portfolio = Portfolio{ .lots = &lots, .allocator = allocator };
|
||||
|
||||
// Account A: should only see AAPL (XLV is fully closed there)
|
||||
const pos_a = try portfolio.positionsForAccount(allocator, "Acct A");
|
||||
defer allocator.free(pos_a);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), pos_a.len);
|
||||
try std.testing.expectEqualStrings("AAPL", pos_a[0].symbol);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 10.0), pos_a[0].shares, 0.01);
|
||||
|
||||
// Account B: should see XLV with 50 shares
|
||||
const pos_b = try portfolio.positionsForAccount(allocator, "Acct B");
|
||||
defer allocator.free(pos_b);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), pos_b.len);
|
||||
try std.testing.expectEqualStrings("XLV", pos_b[0].symbol);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 50.0), pos_b[0].shares, 0.01);
|
||||
}
|
||||
|
|
|
|||
178
src/tui.zig
178
src/tui.zig
|
|
@ -95,7 +95,6 @@ pub const InputMode = enum {
|
|||
symbol_input,
|
||||
help,
|
||||
account_picker,
|
||||
account_search,
|
||||
};
|
||||
|
||||
pub const StyledLine = struct {
|
||||
|
|
@ -310,15 +309,8 @@ 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 (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_list: std.ArrayList([]const u8) = .empty, // distinct accounts from portfolio lots (borrowed from portfolio)
|
||||
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
|
||||
|
|
@ -395,9 +387,6 @@ 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();
|
||||
|
|
@ -630,44 +619,19 @@ 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"
|
||||
|
||||
if (key.codepoint == vaxis.Key.escape or key.codepoint == 'q') {
|
||||
// Cancel: return to normal mode without changing the filter
|
||||
if (key.codepoint == vaxis.Key.escape) {
|
||||
self.mode = .normal;
|
||||
return ctx.consumeAndRedraw();
|
||||
}
|
||||
|
||||
// Confirm: apply the selected account filter
|
||||
if (key.codepoint == vaxis.Key.enter) {
|
||||
self.applyAccountPickerSelection();
|
||||
return ctx.consumeAndRedraw();
|
||||
}
|
||||
|
||||
// '/' 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
|
||||
// Use the keymap for navigation actions
|
||||
const action = self.keymap.matchAction(key) orelse return;
|
||||
switch (action) {
|
||||
.select_next => {
|
||||
|
|
@ -693,111 +657,6 @@ 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) {
|
||||
|
|
@ -823,31 +682,12 @@ 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 {
|
||||
// Free the old owned copy
|
||||
if (self.account_filter) |old| self.allocator.free(old);
|
||||
if (self.filtered_positions) |fp| self.allocator.free(fp);
|
||||
self.filtered_positions = null;
|
||||
|
||||
if (name) |n| {
|
||||
self.account_filter = self.allocator.dupe(u8, n) catch null;
|
||||
if (self.portfolio) |pf| {
|
||||
self.filtered_positions = pf.positionsForAccount(self.allocator, n) catch null;
|
||||
}
|
||||
} else {
|
||||
self.account_filter = null;
|
||||
}
|
||||
|
|
@ -1506,11 +1346,7 @@ 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();
|
||||
if (self.analysis_result) |*ar| ar.deinit(self.allocator);
|
||||
if (self.classification_map) |*cm| cm.deinit();
|
||||
|
|
@ -1612,7 +1448,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 or self.mode == .account_search) {
|
||||
} else if (self.mode == .account_picker) {
|
||||
try portfolio_tab.drawAccountPicker(self, ctx.arena, buf, width, height);
|
||||
} else {
|
||||
switch (self.active_tab) {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ pub fn loadData(app: *App) void {
|
|||
// Look for metadata.srf next to the portfolio file
|
||||
if (app.portfolio_path) |ppath| {
|
||||
// Derive metadata path: same directory as portfolio, named "metadata.srf"
|
||||
const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, std.fs.path.sep)) |idx| idx + 1 else 0;
|
||||
const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, '/')) |idx| idx + 1 else 0;
|
||||
const meta_path = std.fmt.allocPrint(app.allocator, "{s}metadata.srf", .{ppath[0..dir_end]}) catch return;
|
||||
defer app.allocator.free(meta_path);
|
||||
|
||||
|
|
@ -40,7 +40,23 @@ pub fn loadData(app: *App) void {
|
|||
}
|
||||
|
||||
// Load account tax type metadata file (optional)
|
||||
app.ensureAccountMap();
|
||||
if (app.account_map == null) {
|
||||
if (app.portfolio_path) |ppath| {
|
||||
const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, '/')) |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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadDataFinish(app, pf, summary);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -199,7 +199,6 @@ pub fn loadPortfolioData(app: *App) void {
|
|||
|
||||
sortPortfolioAllocations(app);
|
||||
buildAccountList(app);
|
||||
recomputeFilteredPositions(app);
|
||||
rebuildPortfolioRows(app);
|
||||
|
||||
const summary = pf_data.summary;
|
||||
|
|
@ -289,14 +288,10 @@ pub fn rebuildPortfolioRows(app: *App) void {
|
|||
|
||||
// Count lots for this symbol (filtered by account when filter is active)
|
||||
var lcount: usize = 0;
|
||||
if (findFilteredPosition(app, a.symbol)) |pos| {
|
||||
lcount = pos.open_lots + pos.closed_lots;
|
||||
} else if (app.account_filter == null) {
|
||||
if (app.portfolio) |pf| {
|
||||
for (pf.lots) |lot| {
|
||||
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
|
||||
lcount += 1;
|
||||
}
|
||||
if (app.portfolio) |pf| {
|
||||
for (pf.lots) |lot| {
|
||||
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
|
||||
if (matchesAccountFilter(app, lot.account)) lcount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -548,73 +543,33 @@ pub fn rebuildPortfolioRows(app: *App) void {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Build the sorted list of distinct account names from portfolio lots.
|
||||
/// Called after portfolio data is loaded or reloaded.
|
||||
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;
|
||||
|
||||
// Collect distinct account names from portfolio lots
|
||||
// Use a set to deduplicate
|
||||
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;
|
||||
lot_accounts.append(app.allocator, acct) catch continue;
|
||||
app.account_list.append(app.allocator, acct) catch continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
// Sort alphabetically
|
||||
std.mem.sort([]const u8, app.account_list.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;
|
||||
|
|
@ -628,41 +583,6 @@ 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);
|
||||
app.filtered_positions = null;
|
||||
const filter = app.account_filter orelse return;
|
||||
const pf = app.portfolio orelse return;
|
||||
app.filtered_positions = pf.positionsForAccount(app.allocator, filter) catch null;
|
||||
}
|
||||
|
||||
/// Look up a symbol in the pre-computed filtered positions.
|
||||
/// Returns null if no filter is active or the symbol isn't in the filtered account.
|
||||
fn findFilteredPosition(app: *const App, symbol: []const u8) ?zfin.Position {
|
||||
const fps = app.filtered_positions orelse return null;
|
||||
for (fps) |pos| {
|
||||
if (std.mem.eql(u8, pos.symbol, symbol) or std.mem.eql(u8, pos.lot_symbol, symbol))
|
||||
return pos;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Check if a lot matches the active account filter.
|
||||
/// Returns true if no filter is active or the lot's account matches.
|
||||
fn matchesAccountFilter(app: *const App, account: ?[]const u8) bool {
|
||||
|
|
@ -672,11 +592,25 @@ fn matchesAccountFilter(app: *const App, account: ?[]const u8) bool {
|
|||
}
|
||||
|
||||
/// Check if an allocation matches the active account filter.
|
||||
/// When filtered, checks against pre-computed filtered_positions.
|
||||
/// Uses the allocation's account field (which is "Multiple" for mixed-account positions).
|
||||
/// For "Multiple" accounts, we need to check if any lot with this symbol belongs to the filtered account.
|
||||
fn allocationMatchesFilter(app: *const App, a: zfin.valuation.Allocation) bool {
|
||||
if (app.account_filter == null) return true;
|
||||
if (app.filtered_positions == null) return false;
|
||||
return findFilteredPosition(app, a.symbol) != null;
|
||||
const filter = app.account_filter orelse return true;
|
||||
// Simple case: allocation has a single account
|
||||
if (!std.mem.eql(u8, a.account, "Multiple")) {
|
||||
return std.mem.eql(u8, a.account, filter);
|
||||
}
|
||||
// "Multiple" account: check if any stock lot for this symbol belongs to the filtered account
|
||||
if (app.portfolio) |pf| {
|
||||
for (pf.lots) |lot| {
|
||||
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
|
||||
if (lot.account) |la| {
|
||||
if (std.mem.eql(u8, la, filter)) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Account-filtered view of an allocation. When a position spans multiple accounts,
|
||||
|
|
@ -690,24 +624,41 @@ const FilteredAlloc = struct {
|
|||
|
||||
/// Compute account-filtered values for an allocation.
|
||||
/// For single-account positions (or no filter), returns the allocation's own values.
|
||||
/// For filtered views, uses pre-computed filtered_positions.
|
||||
/// For "Multiple"-account positions with a filter, sums only the matching lots.
|
||||
fn filteredAllocValues(app: *const App, a: zfin.valuation.Allocation) FilteredAlloc {
|
||||
if (app.account_filter == null) return .{
|
||||
const filter = app.account_filter orelse return .{
|
||||
.shares = a.shares,
|
||||
.cost_basis = a.cost_basis,
|
||||
.market_value = a.market_value,
|
||||
.unrealized_gain_loss = a.unrealized_gain_loss,
|
||||
};
|
||||
if (findFilteredPosition(app, a.symbol)) |pos| {
|
||||
const mv = pos.shares * a.current_price * pos.price_ratio;
|
||||
return .{
|
||||
.shares = pos.shares,
|
||||
.cost_basis = pos.total_cost,
|
||||
.market_value = mv,
|
||||
.unrealized_gain_loss = mv - pos.total_cost,
|
||||
};
|
||||
if (!std.mem.eql(u8, a.account, "Multiple")) return .{
|
||||
.shares = a.shares,
|
||||
.cost_basis = a.cost_basis,
|
||||
.market_value = a.market_value,
|
||||
.unrealized_gain_loss = a.unrealized_gain_loss,
|
||||
};
|
||||
// Sum values from only the lots matching the filter
|
||||
var shares: f64 = 0;
|
||||
var cost: f64 = 0;
|
||||
if (app.portfolio) |pf| {
|
||||
for (pf.lots) |lot| {
|
||||
if (lot.security_type != .stock) continue;
|
||||
if (!std.mem.eql(u8, lot.priceSymbol(), a.symbol)) continue;
|
||||
if (!lot.isOpen()) continue;
|
||||
const la = lot.account orelse "";
|
||||
if (!std.mem.eql(u8, la, filter)) continue;
|
||||
shares += lot.shares;
|
||||
cost += lot.shares * lot.open_price;
|
||||
}
|
||||
}
|
||||
return .{ .shares = 0, .cost_basis = 0, .market_value = 0, .unrealized_gain_loss = 0 };
|
||||
const mv = shares * a.current_price * a.price_ratio;
|
||||
return .{
|
||||
.shares = shares,
|
||||
.cost_basis = cost,
|
||||
.market_value = mv,
|
||||
.unrealized_gain_loss = mv - cost,
|
||||
};
|
||||
}
|
||||
|
||||
/// Totals for the filtered account view (stocks + cash + CDs + options).
|
||||
|
|
@ -945,20 +896,38 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
|
|||
if (app.portfolio) |pf| {
|
||||
for (pf.lots) |lot| {
|
||||
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
|
||||
if (matchesAccountFilter(app, lot.account)) {
|
||||
const ds = lot.open_date.format(&pos_date_buf);
|
||||
const indicator = fmt.capitalGainsIndicator(lot.open_date);
|
||||
date_col = std.fmt.allocPrint(arena, "{s} {s}", .{ ds, indicator }) catch ds;
|
||||
acct_col = lot.account orelse "";
|
||||
break;
|
||||
}
|
||||
const ds = lot.open_date.format(&pos_date_buf);
|
||||
const indicator = fmt.capitalGainsIndicator(lot.open_date);
|
||||
date_col = std.fmt.allocPrint(arena, "{s} {s}", .{ ds, indicator }) catch ds;
|
||||
acct_col = lot.account orelse "";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (app.account_filter) |af| {
|
||||
acct_col = af;
|
||||
} else {
|
||||
acct_col = a.account;
|
||||
// Multi-lot: show account if all lots share the same one
|
||||
if (app.portfolio) |pf| {
|
||||
var common_acct: ?[]const u8 = null;
|
||||
var mixed = false;
|
||||
for (pf.lots) |lot| {
|
||||
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
|
||||
if (common_acct) |ca| {
|
||||
const la = lot.account orelse "";
|
||||
if (!std.mem.eql(u8, ca, la)) {
|
||||
mixed = true;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
common_acct = lot.account orelse "";
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!mixed) {
|
||||
acct_col = common_acct orelse "";
|
||||
} else {
|
||||
acct_col = "Multiple";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const display_weight = if (app.account_filter != null and filtered_total_for_weight > 0)
|
||||
|
|
@ -1285,7 +1254,6 @@ pub fn reloadPortfolioFile(app: *App) void {
|
|||
|
||||
sortPortfolioAllocations(app);
|
||||
buildAccountList(app);
|
||||
recomputeFilteredPositions(app);
|
||||
rebuildPortfolioRows(app);
|
||||
|
||||
// Invalidate analysis data -- it holds pointers into old portfolio memory
|
||||
|
|
@ -1320,92 +1288,28 @@ 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 = 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 });
|
||||
}
|
||||
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 });
|
||||
}
|
||||
|
||||
// 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() });
|
||||
}
|
||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
|
||||
// Scroll so cursor is visible
|
||||
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;
|
||||
const cursor_line = app.account_picker_cursor + account_picker_header_lines;
|
||||
var start: usize = 0;
|
||||
if (cursor_line >= height) {
|
||||
start = cursor_line - height + 2;
|
||||
start = cursor_line - height + 2; // keep one line of padding below
|
||||
}
|
||||
start = @min(start, if (lines.items.len > 0) lines.items.len - 1 else 0);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue