add symbol overlay to see what symbols are
This commit is contained in:
parent
cd6e22f5ba
commit
41027c4efd
5 changed files with 457 additions and 86 deletions
|
|
@ -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 ────────────────────────────
|
||||
|
|
|
|||
302
src/tui.zig
302
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <portfolio.srf> > 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 <portfolio.srf> > 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <portfolio.srf> > 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 <portfolio.srf> > 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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue