diff --git a/src/PortfolioData.zig b/src/PortfolioData.zig index 56a4508..bac6dac 100644 --- a/src/PortfolioData.zig +++ b/src/PortfolioData.zig @@ -84,6 +84,7 @@ const HistoricalPeriod = zfin.valuation.HistoricalPeriod; const Candle = zfin.Candle; const Dividend = zfin.Dividend; const AccountMap = zfin.analysis.AccountMap; +const ClassificationMap = zfin.classification.ClassificationMap; const DataService = zfin.DataService; // ── Public types ────────────────────────────────────────────── @@ -206,6 +207,7 @@ pub const WorkerDelays = struct { candles_ms: usize = 0, dividends_ms: usize = 0, account_map_ms: usize = 0, + classification_map_ms: usize = 0, }; // ── State ───────────────────────────────────────────────────── @@ -286,6 +288,9 @@ dividends_data: ?std.StringHashMap([]const Dividend) = null, account_map_future: ?std.Io.Future(void) = null, account_map_data: ?AccountMap = null, +classification_map_future: ?std.Io.Future(void) = null, +classification_map_data: ?ClassificationMap = null, + // ── Construction ────────────────────────────────────────────── pub fn init(opts: InitOptions) PortfolioData { @@ -383,6 +388,35 @@ pub fn invalidateAccountMap(self: *PortfolioData) void { self.account_map_future = self.io.async(accountMapWorker, .{ self, @as(usize, 0) }); } +/// Per-symbol classification metadata loaded from `metadata.srf`. +/// Blocks on the classification-map worker. Returns null when +/// there's no portfolio loaded or `metadata.srf` doesn't exist / +/// can't be parsed. +/// +/// Used by analysis_tab, review_tab, and the App-level symbol +/// overlay. Sharing a single PortfolioData-scoped copy avoids +/// the duplicate-load problem the per-tab caches had. +pub fn classificationMap(self: *PortfolioData) ?*const ClassificationMap { + self.awaitWorker(&self.classification_map_future); + if (self.classification_map_data) |*cm| return cm; + return null; +} + +/// Drop the cached classification_map and re-spawn its worker so +/// the next `classificationMap()` call re-reads `metadata.srf` +/// from disk. Re-spawn uses delay 0 (refresh is user-initiated). +/// +/// The classification_map's storage lives in the per-load arena; +/// nulling the field is sufficient — the next `arena.reset()` +/// reaps the bytes. We don't call `cm.deinit()` here because +/// that would touch the arena and double-free at reset time. +pub fn invalidateClassificationMap(self: *PortfolioData) void { + if (self.classification_map_future) |*f| _ = f.cancel(self.io); + self.classification_map_future = null; + self.classification_map_data = null; + self.classification_map_future = self.io.async(classificationMapWorker, .{ self, @as(usize, 0) }); +} + /// Drain a worker future. Idempotent (Future.await is itself /// idempotent); safe to call every time. fn awaitWorker(self: *PortfolioData, fut: *?std.Io.Future(void)) void { @@ -428,6 +462,7 @@ pub fn load( self.snapshots_data = null; self.dividends_data = null; self.account_map_data = null; + self.classification_map_data = null; // candles_data lives in candles_arena and survives across // reloads — kept entries are reused, only new symbols hit @@ -600,6 +635,7 @@ pub fn load( self.snapshots_future = self.io.async(snapshotsWorker, .{ self, today, positions_arena, opts.delays.snapshots_ms }); self.dividends_future = self.io.async(dividendsWorker, .{ self, opts.delays.dividends_ms }); self.account_map_future = self.io.async(accountMapWorker, .{ self, opts.delays.account_map_ms }); + self.classification_map_future = self.io.async(classificationMapWorker, .{ self, opts.delays.classification_map_ms }); return .{ .cached_count = load_all.cached_count, @@ -661,6 +697,9 @@ pub fn cancelLoad(self: *PortfolioData) void { if (self.account_map_future) |*f| _ = f.cancel(self.io); self.account_map_future = null; self.account_map_data = null; + if (self.classification_map_future) |*f| _ = f.cancel(self.io); + self.classification_map_future = null; + self.classification_map_data = null; } // ── Internal: workers ──────────────────────────────────────── @@ -741,6 +780,21 @@ fn accountMapWorker(self: *PortfolioData, delay_ms: usize) void { self.account_map_data = self.svc.loadAccountMap(self.allocator(), ppath); } +fn classificationMapWorker(self: *PortfolioData, delay_ms: usize) void { + self.io.sleep(.fromMilliseconds(@intCast(delay_ms)), .real) catch return; + const ppath = self.anchorPath() orelse return; + // Derive metadata.srf path: same directory as the portfolio + // anchor. Mirrors the per-tab loaders we're consolidating. + const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, std.fs.path.sep)) |idx| idx + 1 else 0; + const meta_path = std.fmt.allocPrint(self.arena.child_allocator, "{s}metadata.srf", .{ppath[0..dir_end]}) catch return; + defer self.arena.child_allocator.free(meta_path); + + const file_data = std.Io.Dir.cwd().readFileAlloc(self.io, meta_path, self.arena.child_allocator, .limited(1024 * 1024)) catch return; + defer self.arena.child_allocator.free(file_data); + + self.classification_map_data = zfin.classification.parseClassificationFile(self.allocator(), file_data) catch null; +} + fn dupePositions(arena: Allocator, src: []const zfin.Position) []const zfin.Position { const dup = arena.alloc(zfin.Position, src.len) catch return &.{}; @memcpy(dup, src); @@ -830,6 +884,8 @@ test "PortfolioData.cancelLoad: idempotent on idle state" { try testing.expect(pd.snapshots_data == null); try testing.expect(pd.account_map_future == null); try testing.expect(pd.account_map_data == null); + try testing.expect(pd.classification_map_future == null); + try testing.expect(pd.classification_map_data == null); } test "PortfolioData.candles: returns null after cancelLoad with no data" { @@ -849,6 +905,7 @@ test "PortfolioData.candles: returns null after cancelLoad with no data" { try testing.expect(pd.dividends() == null); try testing.expect(pd.snapshots() == null); try testing.expect(pd.accountMap() == null); + try testing.expect(pd.classificationMap() == null); } test "PortfolioData.load: NoPaths error on empty paths slice" { @@ -979,6 +1036,7 @@ test "PortfolioData.WorkerDelays: defaults are all zero" { try testing.expectEqual(@as(usize, 0), d.candles_ms); try testing.expectEqual(@as(usize, 0), d.dividends_ms); try testing.expectEqual(@as(usize, 0), d.account_map_ms); + try testing.expectEqual(@as(usize, 0), d.classification_map_ms); } // ── Persistent candle cache tests ──────────────────────────── diff --git a/src/tui.zig b/src/tui.zig index ae19b36..bd31af6 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -2,6 +2,7 @@ const std = @import("std"); const vaxis = @import("vaxis"); const zfin = @import("root.zig"); const fmt = @import("format.zig"); +const Money = @import("Money.zig"); const cli = @import("commands/common.zig"); const portfolio_loader = @import("portfolio_loader.zig"); const stderr = @import("stderr.zig"); @@ -463,6 +464,13 @@ pub const App = struct { // is unobservable. symbol_buf: [16]u8 = undefined, symbol_owned: bool = false, + /// Symbol the overlay popup is currently showing details for. + /// Empty when the overlay is closed. Bytes live in + /// `overlay_symbol_buf`. Set/cleared via `toggleOverlay`. + overlay_symbol: []const u8 = "", + // SAFETY: paired with `overlay_symbol`; only the prefix + // overlay_symbol_buf[0..overlay_symbol.len] is observable. + overlay_symbol_buf: [16]u8 = undefined, scroll_offset: usize = 0, visible_height: u16 = 24, // updated each draw /// Monotonic counter incremented each draw frame. Passed to the @@ -1150,6 +1158,28 @@ pub const App = struct { self.resetSymbolData(); } + /// Open / close / re-target the symbol-info overlay. Behavior: + /// - overlay closed → open with `sym`. + /// - overlay open, same `sym` as cursor → close. + /// - overlay open, different `sym` → re-target to `sym`. + /// + /// `sym` is empty (no cursor on a symbol-bearing row) → close + /// the overlay if open, no-op if closed. + pub fn toggleOverlay(self: *App, sym: []const u8) void { + if (sym.len == 0) { + self.overlay_symbol = ""; + return; + } + if (self.overlay_symbol.len > 0 and std.ascii.eqlIgnoreCase(self.overlay_symbol, sym)) { + self.overlay_symbol = ""; + return; + } + const len = @min(sym.len, self.overlay_symbol_buf.len); + @memcpy(self.overlay_symbol_buf[0..len], sym[0..len]); + for (self.overlay_symbol_buf[0..len]) |*c| c.* = std.ascii.toUpper(c.*); + self.overlay_symbol = self.overlay_symbol_buf[0..len]; + } + fn resetSymbolData(self: *App) void { // Tab-private symbol-bound state is dropped via each tab's // onSymbolChange hook (where defined). Distinct from @@ -1367,6 +1397,29 @@ pub const App = struct { const status_surface = try self.drawStatusBar(ctx, max_size.width); try children.append(ctx.arena, .{ .origin = .{ .row = @intCast(max_size.height - 1), .col = 0 }, .surface = status_surface }); + // Symbol overlay (`K` key). Anchored to the bottom-right + // of the content area with z_index=1 so the vaxis + // compositor renders it on top of the tab content. Width + // and height auto-fit; we clamp the origin so it always + // stays inside the viewport even on narrow terminals. + if (self.overlay_symbol.len > 0) { + if (try self.drawSymbolOverlay(ctx, max_size.width, content_height)) |ov| { + const row: u16 = if (ov.size.height + 1 < max_size.height - 1) + @intCast(max_size.height - 1 - ov.size.height) + else + 1; + const col: u16 = if (ov.size.width < max_size.width) + @intCast(max_size.width - ov.size.width) + else + 0; + try children.append(ctx.arena, .{ + .origin = .{ .row = row, .col = col }, + .surface = ov, + .z_index = 1, + }); + } + } + return .{ .size = max_size, .widget = self.widget(), .buffer = &.{}, .children = try children.toOwnedSlice(ctx.arena) }; } @@ -1552,6 +1605,192 @@ pub const App = struct { } } + /// Build the symbol-info overlay surface. Returns null when + /// nothing useful can be rendered (no portfolio loaded). + /// Anchored bottom-right by the caller via SubSurface origin. + /// + /// Width is fixed at a comfortable column count (or whatever + /// fits if the terminal is narrower); height auto-sizes to + /// the line count. The compositor draws it on top of the + /// content area via z_index=1. + fn drawSymbolOverlay( + self: *App, + ctx: vaxis.vxfw.DrawContext, + max_width: u16, + max_height: u16, + ) !?vaxis.vxfw.Surface { + const lines = try self.buildOverlayLines(ctx.arena); + if (lines.len == 0) return null; + + // Width: longest line + 4 for borders + a comfortable cap + // so the box doesn't dominate wide terminals. + const overlay_width_cap: usize = 60; + var content_w: usize = 0; + for (lines) |ln| { + // Approximate display width by byte length. ASCII-only + // for the v1 content; emoji/multibyte would need a + // fancier measurement. + if (ln.text.len > content_w) content_w = ln.text.len; + } + content_w = @min(content_w, overlay_width_cap); + const total_w: u16 = @intCast(@min(content_w + 4, max_width)); + const total_h: u16 = @intCast(@min(lines.len + 2, max_height)); + if (total_w < 4 or total_h < 3) return null; + + const buf = try ctx.arena.alloc(vaxis.Cell, @as(usize, total_w) * @as(usize, total_h)); + const th = self.theme; + const box_style = th.contentStyle(); + // Fill with spaces in box_style (overwrites whatever was + // beneath; opaque overlay). + for (buf) |*c| c.* = .{ .char = .{ .grapheme = " " }, .style = box_style }; + + // Border. Top + bottom rows + first/last column. + const tl = "┌"; + const tr = "┐"; + const bl = "└"; + const br = "┘"; + const h_border = "─"; + const v_border = "│"; + // Top + buf[0] = .{ .char = .{ .grapheme = tl }, .style = box_style }; + buf[total_w - 1] = .{ .char = .{ .grapheme = tr }, .style = box_style }; + for (1..total_w - 1) |c| buf[c] = .{ .char = .{ .grapheme = h_border }, .style = box_style }; + // Bottom + const last_row = (@as(usize, total_h) - 1) * @as(usize, total_w); + buf[last_row] = .{ .char = .{ .grapheme = bl }, .style = box_style }; + buf[last_row + total_w - 1] = .{ .char = .{ .grapheme = br }, .style = box_style }; + for (1..total_w - 1) |c| buf[last_row + c] = .{ .char = .{ .grapheme = h_border }, .style = box_style }; + // Left + right verticals + for (1..total_h - 1) |r| { + buf[r * total_w] = .{ .char = .{ .grapheme = v_border }, .style = box_style }; + buf[r * total_w + total_w - 1] = .{ .char = .{ .grapheme = v_border }, .style = box_style }; + } + + // Content rows: render each StyledLine starting at col 2, + // row 1+i, capped at content_w bytes. + const inner_w: usize = @as(usize, total_w) - 4; + for (lines, 0..) |line, i| { + const row = 1 + i; + if (row >= total_h - 1) break; + const row_off = row * @as(usize, total_w) + 2; + // Reset interior cells to the line's style. + for (0..inner_w) |c| { + buf[row_off + c] = .{ .char = .{ .grapheme = " " }, .style = line.style }; + } + // Write text bytes (ASCII assumption; if a name has + // multibyte characters we'd need fancier handling but + // in practice security names are ASCII). + const limit = @min(line.text.len, inner_w); + for (0..limit) |c| { + buf[row_off + c] = .{ + .char = .{ .grapheme = glyph(line.text[c]) }, + .style = line.style, + }; + } + } + + return .{ + .size = .{ .width = total_w, .height = total_h }, + .widget = self.widget(), + .buffer = buf, + .children = &.{}, + }; + } + + /// Build the overlay's content lines for the currently-active + /// `overlay_symbol`. Pulls name + classification from + /// `pd.classificationMap()`; pulls allocation data (shares, + /// price, market value, weight) from `pd.summary`. + fn buildOverlayLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { + var lines: std.ArrayList(StyledLine) = .empty; + const th = self.theme; + const sym = self.overlay_symbol; + + // Header: symbol (and name if available). + const cm_opt = self.portfolio.classificationMap(); + const ce_opt: ?*const zfin.classification.ClassificationEntry = blk: { + if (cm_opt) |cm| { + for (cm.entries) |*e| { + if (std.mem.eql(u8, e.symbol, sym)) break :blk e; + } + } + break :blk null; + }; + + const display_name: []const u8 = if (ce_opt) |ce| (ce.name orelse sym) else sym; + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " {s}", .{sym}), + .style = th.headerStyle(), + }); + if (display_name.ptr != sym.ptr and display_name.len > 0) { + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " {s}", .{display_name}), + .style = th.contentStyle(), + }); + } + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + + // Classification rows. + if (ce_opt) |ce| { + if (ce.asset_class) |ac| { + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " Asset class: {s}", .{ac}), + .style = th.contentStyle(), + }); + } + if (ce.sector) |s| { + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " Sector: {s}", .{s}), + .style = th.contentStyle(), + }); + } + if (ce.geo) |g| { + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " Geo: {s}", .{g}), + .style = th.contentStyle(), + }); + } + } else { + try lines.append(arena, .{ + .text = " (no metadata.srf entry)", + .style = th.mutedStyle(), + }); + } + + // Allocation row from pd.summary. + if (self.portfolio.summary) |summary| { + for (summary.allocations) |a| { + if (!std.mem.eql(u8, a.symbol, sym)) continue; + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " Shares: {d:.4}", .{a.shares}), + .style = th.contentStyle(), + }); + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " Price: {f}", .{Money.from(a.current_price)}), + .style = th.contentStyle(), + }); + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " Value: {f}", .{Money.from(a.market_value)}), + .style = th.contentStyle(), + }); + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " Weight: {d:.2}%", .{a.weight * 100.0}), + .style = th.contentStyle(), + }); + break; + } + } + + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ + .text = " K to close", + .style = th.mutedStyle(), + }); + + return lines.toOwnedSlice(arena); + } + fn drawStatusBar(self: *App, ctx: vaxis.vxfw.DrawContext, width: u16) !vaxis.vxfw.Surface { const t = self.theme; const buf = try ctx.arena.alloc(vaxis.Cell, width); @@ -2461,3 +2700,66 @@ test "renderBrailleToStyledLines: full price label renders for portfolios over $ try testing.expect(std.mem.indexOf(u8, rendered.items, ".89") != null); try testing.expect(std.mem.indexOf(u8, rendered.items, ",") != null); } + +// ── overlay state transitions ───────────────────────────────── +// +// The `toggleOverlay` semantics: +// - empty `sym` argument → close overlay (no-op when closed). +// - overlay closed, non-empty → open with sym. +// - overlay open, same sym → close. +// - overlay open, different sym → re-target to sym. +// +// We test it by manipulating App fields directly. App.toggleOverlay +// only reads/writes `overlay_symbol` and `overlay_symbol_buf`, so +// we don't need a fully-wired App. + +test "toggleOverlay: opens when closed and sym is non-empty" { + var app: App = undefined; + app.overlay_symbol = ""; + app.toggleOverlay("VTI"); + try testing.expectEqualStrings("VTI", app.overlay_symbol); +} + +test "toggleOverlay: closes when same sym is passed twice" { + var app: App = undefined; + app.overlay_symbol = ""; + app.toggleOverlay("VTI"); + app.toggleOverlay("VTI"); + try testing.expectEqualStrings("", app.overlay_symbol); +} + +test "toggleOverlay: re-targets when sym differs" { + var app: App = undefined; + app.overlay_symbol = ""; + app.toggleOverlay("VTI"); + app.toggleOverlay("BND"); + try testing.expectEqualStrings("BND", app.overlay_symbol); +} + +test "toggleOverlay: empty sym closes overlay if open, no-op when closed" { + var app: App = undefined; + app.overlay_symbol = ""; + app.toggleOverlay(""); + try testing.expectEqualStrings("", app.overlay_symbol); + + app.toggleOverlay("VTI"); + app.toggleOverlay(""); + try testing.expectEqualStrings("", app.overlay_symbol); +} + +test "toggleOverlay: uppercases the stored symbol" { + var app: App = undefined; + app.overlay_symbol = ""; + app.toggleOverlay("vti"); + try testing.expectEqualStrings("VTI", app.overlay_symbol); +} + +test "toggleOverlay: same-sym match is case-insensitive" { + var app: App = undefined; + app.overlay_symbol = ""; + app.toggleOverlay("VTI"); + // Lowercase "vti" should match the stored "VTI" and close, + // not re-target (which would be a needless redraw). + app.toggleOverlay("vti"); + try testing.expectEqualStrings("", app.overlay_symbol); +} diff --git a/src/tui/analysis_tab.zig b/src/tui/analysis_tab.zig index 1a247f3..471bebf 100644 --- a/src/tui/analysis_tab.zig +++ b/src/tui/analysis_tab.zig @@ -28,11 +28,6 @@ pub const State = struct { /// Computed analysis output. Owned by State; freed in /// `deinit` and `reload`. result: ?zfin.analysis.AnalysisResult = null, - /// Per-portfolio classification metadata (`metadata.srf`). - /// Used only by analysis today; lives here because no other - /// tab consumes it. Loaded lazily on first activation; freed - /// in `deinit`. - classification_map: ?zfin.classification.ClassificationMap = null, /// Sector display granularity. Cycled via `cycle_sector_granularity` /// action. Default `mid` matches the CLI default /// (`zfin analysis` without `--sector-detail`). @@ -65,7 +60,6 @@ pub const tab = struct { pub fn deinit(state: *State, app: *App) void { if (state.result) |*ar| ar.deinit(app.allocator); - if (state.classification_map) |*cm| cm.deinit(); state.* = .{}; } @@ -78,19 +72,16 @@ pub const tab = struct { pub const deactivate = framework.noopDeactivate(State); /// Force re-fetch on user request. Frees the analysis result - /// AND the shared `account_map` on App (analysis's refresh - /// also re-reads accounts.srf). The classification_map persists - /// — it's per-portfolio, not per-symbol or per-refresh. + /// and invalidates the shared `account_map` + `classification_map` + /// on PortfolioData so the next load re-reads `accounts.srf` + /// and `metadata.srf` from disk (the user may have edited + /// either). pub fn reload(state: *State, app: *App) !void { if (state.result) |*ar| ar.deinit(app.allocator); state.result = null; state.loaded = false; - // Refresh-analysis intentionally drops the shared account - // map so the next load re-reads `accounts.srf` from disk - // (the user may have edited it). PortfolioData re-spawns - // the account_map worker with delay=0 so the next - // accountMap() call resolves quickly. app.portfolio.invalidateAccountMap(); + app.portfolio.invalidateClassificationMap(); loadData(state, app); } @@ -126,18 +117,18 @@ pub const tab = struct { /// memory (allocations/symbols), so we have to invalidate /// it before the underlying data is freed. /// - /// Also drops `classification_map` because the user may - /// have re-enriched alongside their portfolio edits. - /// `account_map` lives on App (shared with portfolio_tab) - /// and is reset by PortfolioData itself. + /// `classification_map` and `account_map` live on + /// PortfolioData and are reset by pd's own load path — + /// nothing to free here for those. /// /// Eagerly recomputes only if this tab is currently active — /// otherwise next `activate` will lazy-load. pub fn onPortfolioReload(state: *State, app: *App) void { if (state.result) |*ar| ar.deinit(app.allocator); state.result = null; - if (state.classification_map) |*cm| cm.deinit(); - state.classification_map = null; + // classification_map lives on PortfolioData now; the + // reload's broadcast already invalidated it via pd's own + // reset path. Nothing to free here. state.loaded = false; if (app.active_tab == .analysis) { tab.activate(state, app) catch |err| std.log.debug("analysis activate failed: {t}", .{err}); @@ -156,33 +147,14 @@ fn loadData(state: *State, app: *App) void { const pf = app.portfolio.file orelse return; const summary_ptr = if (app.portfolio.summary) |*s| s else return; - // Load classification metadata file - if (state.classification_map == null) { - // Look for metadata.srf next to the portfolio file - if (app.anchorPath()) |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 meta_path = std.fmt.allocPrint(app.allocator, "{s}metadata.srf", .{ppath[0..dir_end]}) catch return; - defer app.allocator.free(meta_path); - - const file_data = std.Io.Dir.cwd().readFileAlloc(app.io, meta_path, app.allocator, .limited(1024 * 1024)) catch { - app.setStatus("No metadata.srf found. Run: zfin enrich > metadata.srf"); - return; - }; - defer app.allocator.free(file_data); - - state.classification_map = zfin.classification.parseClassificationFile(app.allocator, file_data) catch { - app.setStatus("Error parsing metadata.srf"); - return; - }; - } - } - loadDataFinish(state, app, pf, summary_ptr.*); } fn loadDataFinish(state: *State, app: *App, pf: zfin.Portfolio, summary: zfin.valuation.PortfolioSummary) void { - const cm = state.classification_map orelse { + // classificationMap() blocks on the classification-map + // worker; first call may briefly wait while metadata.srf + // parses. Returns null when metadata.srf is missing. + const cm_ptr = app.portfolio.classificationMap() orelse { app.setStatus("No classification data. Run: zfin enrich > metadata.srf"); return; }; @@ -197,7 +169,7 @@ fn loadDataFinish(state: *State, app: *App, pf: zfin.Portfolio, summary: zfin.va state.result = zfin.analysis.analyzePortfolio( app.allocator, summary.allocations, - cm, + cm_ptr.*, pf, summary.total_value, acct_map_opt, @@ -220,7 +192,8 @@ pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]c total_value = summary.total_value; if (app.portfolio.file) |pf| { const benchmark = @import("../analytics/benchmark.zig"); - const cm_entries = if (state.classification_map) |cm| cm.entries else &.{}; + const cm_entries: []const zfin.classification.ClassificationEntry = + if (app.portfolio.classificationMap()) |cm| cm.entries else &.{}; const split = benchmark.deriveAllocationSplit( summary.allocations, cm_entries, @@ -529,7 +502,7 @@ test "tab.init produces zero-defaulted state" { try tab.init(&state, &dummy_app); try testing.expectEqual(false, state.loaded); try testing.expect(state.result == null); - try testing.expect(state.classification_map == null); + // classification_map lives on PortfolioData now (not on tab state). // Default sector granularity is mid (matches CLI default). try testing.expectEqual(zfin.analysis.Granularity.mid, state.sector_granularity); } diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index 3723e24..153d90d 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -135,6 +135,11 @@ pub const Action = enum { /// Select the cursor row's symbol as the currently-active /// symbol for the per-symbol tabs (quote/perf/options/etc.). select_symbol, + /// Toggle the symbol-info overlay popup. Open at cursor + /// row's symbol when closed; close when open and cursor is + /// on the same symbol; re-target when open and cursor is on + /// a different symbol. + toggle_overlay, }; // ── Tab-private state ───────────────────────────────────────── @@ -260,6 +265,10 @@ pub const meta: framework.TabMeta(Action) = .{ .{ .action = .clear_account_filter, .key = .{ .codepoint = vaxis.Key.escape } }, .{ .action = .select_symbol, .key = .{ .codepoint = 's' } }, .{ .action = .select_symbol, .key = .{ .codepoint = vaxis.Key.space } }, + // Capital K: Vim-style "what is this" toggle. Opens + // (or re-targets) the symbol-info overlay for the + // cursor row's symbol. + .{ .action = .toggle_overlay, .key = .{ .codepoint = 'K' } }, }, .action_labels = std.enums.EnumArray(Action, []const u8).init(.{ .expand_collapse = "Expand/collapse position", @@ -269,12 +278,14 @@ pub const meta: framework.TabMeta(Action) = .{ .open_account_picker = "Filter by account", .clear_account_filter = "Clear account filter", .select_symbol = "Select symbol", + .toggle_overlay = "Show symbol details", }), .status_hints = &.{ .sort_col_prev, .sort_col_next, .sort_reverse, .open_account_picker, + .toggle_overlay, }, }; @@ -397,6 +408,17 @@ pub const tab = struct { const msg = std.fmt.bufPrint(&tmp_buf, "Active: {s}", .{row.symbol}) catch "Active"; app.setStatus(msg); }, + .toggle_overlay => { + // Resolve the cursor row's symbol; pass empty + // string when not on a symbol row so toggleOverlay + // closes any open overlay. + const sym: []const u8 = blk: { + if (state.rows.items.len == 0) break :blk ""; + if (state.cursor >= state.rows.items.len) break :blk ""; + break :blk state.rows.items[state.cursor].symbol; + }; + app.toggleOverlay(sym); + }, } } diff --git a/src/tui/review_tab.zig b/src/tui/review_tab.zig index 21267da..d73896c 100644 --- a/src/tui/review_tab.zig +++ b/src/tui/review_tab.zig @@ -63,6 +63,14 @@ pub const Action = enum { /// sector-level findings. The latter case is harmless — the /// active symbol just doesn't change anything per-tab. select_symbol, + /// Toggle the symbol-info overlay popup. Open at cursor + /// row's symbol when closed; close when open and cursor is + /// on the same symbol; re-target when open and cursor is on + /// a different symbol. For findings rows, the symbol used is + /// the finding's `target` (which is the symbol for + /// per-symbol findings; harmless no-op label like "Technology" + /// for sector-level ones). + toggle_overlay, }; /// Modal sub-state. `.normal` lets keystrokes flow through the @@ -96,10 +104,6 @@ pub const State = struct { loaded: bool = false, /// Computed view. Owned by State; freed in `deinit` and `reload`. view: ?review_view.ReviewView = null, - /// Per-portfolio classification metadata (`metadata.srf`). Loaded - /// lazily on first activation, kept across reloads (cheap and - /// rarely-changing). Freed in `deinit`. - classification_map: ?zfin.classification.ClassificationMap = null, /// Active sort field. Default `.sector` (asc) provides the /// "grouped by sector with symbol-asc tiebreaker" entry state /// — see `views/review.sortRows` for why the sector column @@ -193,6 +197,9 @@ pub const meta: framework.TabMeta(Action) = .{ // Mirrors portfolio_tab so muscle memory transfers. .{ .action = .select_symbol, .key = .{ .codepoint = 's' } }, .{ .action = .select_symbol, .key = .{ .codepoint = vaxis.Key.space } }, + // Capital K: Vim-style "what is this" toggle. Same + // binding as portfolio_tab so the gesture transfers. + .{ .action = .toggle_overlay, .key = .{ .codepoint = 'K' } }, }, .action_labels = std.enums.EnumArray(Action, []const u8).init(.{ .sort_col_next = "Sort: next column", @@ -203,12 +210,14 @@ pub const meta: framework.TabMeta(Action) = .{ .unack = "Un-acknowledge finding", .toggle_show_acked = "Toggle show acked findings", .select_symbol = "Select symbol", + .toggle_overlay = "Show symbol details", }), .status_hints = &.{ .ack, .unack, .toggle_show_acked, .select_symbol, + .toggle_overlay, .sort_col_prev, .sort_col_next, }, @@ -254,20 +263,19 @@ pub const tab = struct { pub const deactivate = framework.noopDeactivate(State); /// Manual refresh: drops the cached view and re-builds. Also - /// clears the dividend cache (in case new dividends arrived) and - /// the shared `account_map` so accounts.srf gets re-read. The - /// classification_map persists — it's per-portfolio, not - /// per-refresh. The journal is reloaded too in case the user - /// edited acknowledgments.srf out-of-band. + /// invalidates the shared `account_map` and `classification_map` + /// on PortfolioData so the next load re-reads `accounts.srf` / + /// `metadata.srf` from disk (the user may have edited either). + /// The journal is reloaded too in case the user edited + /// acknowledgments.srf out-of-band. pub fn reload(state: *State, app: *App) !void { if (state.view) |*v| v.deinit(app.allocator); state.view = null; if (state.findings_view) |*fv| fv.deinit(app.allocator); state.findings_view = null; state.loaded = false; - // Drop the cached account_map so the worker re-reads - // accounts.srf on the next accountMap() call. app.portfolio.invalidateAccountMap(); + app.portfolio.invalidateClassificationMap(); loadJournal(state, app); loadData(state, app); } @@ -306,6 +314,7 @@ pub const tab = struct { rebuildFindingsView(state, app); }, .select_symbol => selectSymbolAtCursor(state, app), + .toggle_overlay => toggleOverlayAtCursor(state, app), } } @@ -785,6 +794,31 @@ fn selectSymbolAtCursor(state: *State, app: *App) void { app.setStatus(msg); } +/// Resolve the cursor row's symbol and call `app.toggleOverlay`. +/// Empty cursor section → empty symbol → toggleOverlay closes +/// any open overlay. For findings rows, the target field doubles +/// as the symbol (per-symbol findings) or as a non-symbol label +/// (sector findings) — the latter makes the overlay show "no +/// metadata.srf entry" for the bogus key, which is fine. +fn toggleOverlayAtCursor(state: *State, app: *App) void { + const symbol: []const u8 = switch (cursorSection(state)) { + .empty => "", + .holdings => blk: { + const view = state.view orelse break :blk ""; + const local = cursorLocalIndex(state); + if (local >= view.rows.len) break :blk ""; + break :blk view.rows[local].symbol; + }, + .findings => blk: { + const fv = state.findings_view orelse break :blk ""; + const local = cursorLocalIndex(state); + if (local >= fv.rows.len) break :blk ""; + break :blk fv.rows[local].target; + }, + }; + app.toggleOverlay(symbol); +} + /// Free `state.note_fragments` items + the slice. Idempotent. fn clearNoteFragments(state: *State, allocator: std.mem.Allocator) void { for (state.note_fragments.items) |frag| allocator.free(frag); @@ -878,39 +912,23 @@ fn loadData(state: *State, app: *App) void { const pf = app.portfolio.file orelse return; const summary_ptr = if (app.portfolio.summary) |*s| s else return; - // Lazy-load classifications (per-tab; analysis_tab loads its own copy). - if (state.classification_map == null) { - if (app.anchorPath()) |ppath| { - const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, std.fs.path.sep)) |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); - - const file_data = std.Io.Dir.cwd().readFileAlloc(app.io, meta_path, app.allocator, .limited(1024 * 1024)) catch { - app.setStatus("No metadata.srf found. Run: zfin enrich > metadata.srf"); - return; - }; - defer app.allocator.free(file_data); - - state.classification_map = zfin.classification.parseClassificationFile(app.allocator, file_data) catch { - app.setStatus("Error parsing metadata.srf"); - return; - }; - } - } - // Tier 1 accessors block on their respective worker futures. - // Review needs candles, dividends, and the account map; this - // is the first tab where all three workers' costs may show - // up as visible wait. Most users will hit portfolio first - // (where snapshots blocks if needed) before navigating to - // review, so the candles + dividends workers are usually - // done by the time we get here. + // Review needs candles, dividends, classification, and the + // account map; this is the first tab where all four workers' + // costs may show up as visible wait. Most users will hit + // portfolio first (where snapshots blocks if needed) before + // navigating to review, so the candles + dividends workers + // are usually done by the time we get here. const candle_map = app.portfolio.candles() orelse { app.setStatus("Portfolio data not loaded"); return; }; const dividend_map = app.portfolio.dividends(); const acct_map_opt: ?zfin.analysis.AccountMap = if (app.portfolio.accountMap()) |amp| amp.* else null; + const cm_ptr = app.portfolio.classificationMap() orelse { + app.setStatus("No metadata.srf found. Run: zfin enrich > metadata.srf"); + return; + }; if (state.view) |*v| v.deinit(app.allocator); state.view = review_view.buildReview( @@ -920,7 +938,7 @@ fn loadData(state: *State, app: *App) void { candle_map, dividend_map, pf, - state.classification_map orelse return, + cm_ptr.*, acct_map_opt, app.today, app.anchorPath() orelse "", @@ -1018,7 +1036,6 @@ pub fn deinitState(state: *State, allocator: std.mem.Allocator) void { if (state.view) |*v| v.deinit(allocator); if (state.findings_view) |*fv| fv.deinit(allocator); if (state.journal) |*j| j.deinit(); - if (state.classification_map) |*cm| cm.deinit(); for (state.note_fragments.items) |frag| allocator.free(frag); state.note_fragments.deinit(allocator); state.* = .{}; @@ -2307,7 +2324,6 @@ test "deinitState: cleans up view (leak check)" { var state: State = .{ .loaded = true, .view = view, - .classification_map = null, // owned by caller in this test .sort_field = .sector, .sort_dir = .asc, };