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 Candle = zfin.Candle;
|
||||||
const Dividend = zfin.Dividend;
|
const Dividend = zfin.Dividend;
|
||||||
const AccountMap = zfin.analysis.AccountMap;
|
const AccountMap = zfin.analysis.AccountMap;
|
||||||
|
const ClassificationMap = zfin.classification.ClassificationMap;
|
||||||
const DataService = zfin.DataService;
|
const DataService = zfin.DataService;
|
||||||
|
|
||||||
// ── Public types ──────────────────────────────────────────────
|
// ── Public types ──────────────────────────────────────────────
|
||||||
|
|
@ -206,6 +207,7 @@ pub const WorkerDelays = struct {
|
||||||
candles_ms: usize = 0,
|
candles_ms: usize = 0,
|
||||||
dividends_ms: usize = 0,
|
dividends_ms: usize = 0,
|
||||||
account_map_ms: usize = 0,
|
account_map_ms: usize = 0,
|
||||||
|
classification_map_ms: usize = 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── State ─────────────────────────────────────────────────────
|
// ── State ─────────────────────────────────────────────────────
|
||||||
|
|
@ -286,6 +288,9 @@ dividends_data: ?std.StringHashMap([]const Dividend) = null,
|
||||||
account_map_future: ?std.Io.Future(void) = null,
|
account_map_future: ?std.Io.Future(void) = null,
|
||||||
account_map_data: ?AccountMap = null,
|
account_map_data: ?AccountMap = null,
|
||||||
|
|
||||||
|
classification_map_future: ?std.Io.Future(void) = null,
|
||||||
|
classification_map_data: ?ClassificationMap = null,
|
||||||
|
|
||||||
// ── Construction ──────────────────────────────────────────────
|
// ── Construction ──────────────────────────────────────────────
|
||||||
|
|
||||||
pub fn init(opts: InitOptions) PortfolioData {
|
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) });
|
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
|
/// Drain a worker future. Idempotent (Future.await is itself
|
||||||
/// idempotent); safe to call every time.
|
/// idempotent); safe to call every time.
|
||||||
fn awaitWorker(self: *PortfolioData, fut: *?std.Io.Future(void)) void {
|
fn awaitWorker(self: *PortfolioData, fut: *?std.Io.Future(void)) void {
|
||||||
|
|
@ -428,6 +462,7 @@ pub fn load(
|
||||||
self.snapshots_data = null;
|
self.snapshots_data = null;
|
||||||
self.dividends_data = null;
|
self.dividends_data = null;
|
||||||
self.account_map_data = null;
|
self.account_map_data = null;
|
||||||
|
self.classification_map_data = null;
|
||||||
|
|
||||||
// candles_data lives in candles_arena and survives across
|
// candles_data lives in candles_arena and survives across
|
||||||
// reloads — kept entries are reused, only new symbols hit
|
// 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.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.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.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 .{
|
return .{
|
||||||
.cached_count = load_all.cached_count,
|
.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);
|
if (self.account_map_future) |*f| _ = f.cancel(self.io);
|
||||||
self.account_map_future = null;
|
self.account_map_future = null;
|
||||||
self.account_map_data = 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 ────────────────────────────────────────
|
// ── Internal: workers ────────────────────────────────────────
|
||||||
|
|
@ -741,6 +780,21 @@ fn accountMapWorker(self: *PortfolioData, delay_ms: usize) void {
|
||||||
self.account_map_data = self.svc.loadAccountMap(self.allocator(), ppath);
|
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 {
|
fn dupePositions(arena: Allocator, src: []const zfin.Position) []const zfin.Position {
|
||||||
const dup = arena.alloc(zfin.Position, src.len) catch return &.{};
|
const dup = arena.alloc(zfin.Position, src.len) catch return &.{};
|
||||||
@memcpy(dup, src);
|
@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.snapshots_data == null);
|
||||||
try testing.expect(pd.account_map_future == null);
|
try testing.expect(pd.account_map_future == null);
|
||||||
try testing.expect(pd.account_map_data == 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" {
|
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.dividends() == null);
|
||||||
try testing.expect(pd.snapshots() == null);
|
try testing.expect(pd.snapshots() == null);
|
||||||
try testing.expect(pd.accountMap() == null);
|
try testing.expect(pd.accountMap() == null);
|
||||||
|
try testing.expect(pd.classificationMap() == null);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "PortfolioData.load: NoPaths error on empty paths slice" {
|
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.candles_ms);
|
||||||
try testing.expectEqual(@as(usize, 0), d.dividends_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.account_map_ms);
|
||||||
|
try testing.expectEqual(@as(usize, 0), d.classification_map_ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Persistent candle cache tests ────────────────────────────
|
// ── 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 vaxis = @import("vaxis");
|
||||||
const zfin = @import("root.zig");
|
const zfin = @import("root.zig");
|
||||||
const fmt = @import("format.zig");
|
const fmt = @import("format.zig");
|
||||||
|
const Money = @import("Money.zig");
|
||||||
const cli = @import("commands/common.zig");
|
const cli = @import("commands/common.zig");
|
||||||
const portfolio_loader = @import("portfolio_loader.zig");
|
const portfolio_loader = @import("portfolio_loader.zig");
|
||||||
const stderr = @import("stderr.zig");
|
const stderr = @import("stderr.zig");
|
||||||
|
|
@ -463,6 +464,13 @@ pub const App = struct {
|
||||||
// is unobservable.
|
// is unobservable.
|
||||||
symbol_buf: [16]u8 = undefined,
|
symbol_buf: [16]u8 = undefined,
|
||||||
symbol_owned: bool = false,
|
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,
|
scroll_offset: usize = 0,
|
||||||
visible_height: u16 = 24, // updated each draw
|
visible_height: u16 = 24, // updated each draw
|
||||||
/// Monotonic counter incremented each draw frame. Passed to the
|
/// Monotonic counter incremented each draw frame. Passed to the
|
||||||
|
|
@ -1150,6 +1158,28 @@ pub const App = struct {
|
||||||
self.resetSymbolData();
|
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 {
|
fn resetSymbolData(self: *App) void {
|
||||||
// Tab-private symbol-bound state is dropped via each tab's
|
// Tab-private symbol-bound state is dropped via each tab's
|
||||||
// onSymbolChange hook (where defined). Distinct from
|
// onSymbolChange hook (where defined). Distinct from
|
||||||
|
|
@ -1367,6 +1397,29 @@ pub const App = struct {
|
||||||
const status_surface = try self.drawStatusBar(ctx, max_size.width);
|
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 });
|
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) };
|
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 {
|
fn drawStatusBar(self: *App, ctx: vaxis.vxfw.DrawContext, width: u16) !vaxis.vxfw.Surface {
|
||||||
const t = self.theme;
|
const t = self.theme;
|
||||||
const buf = try ctx.arena.alloc(vaxis.Cell, width);
|
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, ".89") != null);
|
||||||
try testing.expect(std.mem.indexOf(u8, rendered.items, ",") != 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
|
/// Computed analysis output. Owned by State; freed in
|
||||||
/// `deinit` and `reload`.
|
/// `deinit` and `reload`.
|
||||||
result: ?zfin.analysis.AnalysisResult = null,
|
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`
|
/// Sector display granularity. Cycled via `cycle_sector_granularity`
|
||||||
/// action. Default `mid` matches the CLI default
|
/// action. Default `mid` matches the CLI default
|
||||||
/// (`zfin analysis` without `--sector-detail`).
|
/// (`zfin analysis` without `--sector-detail`).
|
||||||
|
|
@ -65,7 +60,6 @@ pub const tab = struct {
|
||||||
|
|
||||||
pub fn deinit(state: *State, app: *App) void {
|
pub fn deinit(state: *State, app: *App) void {
|
||||||
if (state.result) |*ar| ar.deinit(app.allocator);
|
if (state.result) |*ar| ar.deinit(app.allocator);
|
||||||
if (state.classification_map) |*cm| cm.deinit();
|
|
||||||
state.* = .{};
|
state.* = .{};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,19 +72,16 @@ pub const tab = struct {
|
||||||
pub const deactivate = framework.noopDeactivate(State);
|
pub const deactivate = framework.noopDeactivate(State);
|
||||||
|
|
||||||
/// Force re-fetch on user request. Frees the analysis result
|
/// Force re-fetch on user request. Frees the analysis result
|
||||||
/// AND the shared `account_map` on App (analysis's refresh
|
/// and invalidates the shared `account_map` + `classification_map`
|
||||||
/// also re-reads accounts.srf). The classification_map persists
|
/// on PortfolioData so the next load re-reads `accounts.srf`
|
||||||
/// — it's per-portfolio, not per-symbol or per-refresh.
|
/// and `metadata.srf` from disk (the user may have edited
|
||||||
|
/// either).
|
||||||
pub fn reload(state: *State, app: *App) !void {
|
pub fn reload(state: *State, app: *App) !void {
|
||||||
if (state.result) |*ar| ar.deinit(app.allocator);
|
if (state.result) |*ar| ar.deinit(app.allocator);
|
||||||
state.result = null;
|
state.result = null;
|
||||||
state.loaded = false;
|
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.invalidateAccountMap();
|
||||||
|
app.portfolio.invalidateClassificationMap();
|
||||||
loadData(state, app);
|
loadData(state, app);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,18 +117,18 @@ pub const tab = struct {
|
||||||
/// memory (allocations/symbols), so we have to invalidate
|
/// memory (allocations/symbols), so we have to invalidate
|
||||||
/// it before the underlying data is freed.
|
/// it before the underlying data is freed.
|
||||||
///
|
///
|
||||||
/// Also drops `classification_map` because the user may
|
/// `classification_map` and `account_map` live on
|
||||||
/// have re-enriched alongside their portfolio edits.
|
/// PortfolioData and are reset by pd's own load path —
|
||||||
/// `account_map` lives on App (shared with portfolio_tab)
|
/// nothing to free here for those.
|
||||||
/// and is reset by PortfolioData itself.
|
|
||||||
///
|
///
|
||||||
/// Eagerly recomputes only if this tab is currently active —
|
/// Eagerly recomputes only if this tab is currently active —
|
||||||
/// otherwise next `activate` will lazy-load.
|
/// otherwise next `activate` will lazy-load.
|
||||||
pub fn onPortfolioReload(state: *State, app: *App) void {
|
pub fn onPortfolioReload(state: *State, app: *App) void {
|
||||||
if (state.result) |*ar| ar.deinit(app.allocator);
|
if (state.result) |*ar| ar.deinit(app.allocator);
|
||||||
state.result = null;
|
state.result = null;
|
||||||
if (state.classification_map) |*cm| cm.deinit();
|
// classification_map lives on PortfolioData now; the
|
||||||
state.classification_map = null;
|
// reload's broadcast already invalidated it via pd's own
|
||||||
|
// reset path. Nothing to free here.
|
||||||
state.loaded = false;
|
state.loaded = false;
|
||||||
if (app.active_tab == .analysis) {
|
if (app.active_tab == .analysis) {
|
||||||
tab.activate(state, app) catch |err| std.log.debug("analysis activate failed: {t}", .{err});
|
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 pf = app.portfolio.file orelse return;
|
||||||
const summary_ptr = if (app.portfolio.summary) |*s| s else 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.*);
|
loadDataFinish(state, app, pf, summary_ptr.*);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn loadDataFinish(state: *State, app: *App, pf: zfin.Portfolio, summary: zfin.valuation.PortfolioSummary) void {
|
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");
|
app.setStatus("No classification data. Run: zfin enrich <portfolio.srf> > metadata.srf");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
@ -197,7 +169,7 @@ fn loadDataFinish(state: *State, app: *App, pf: zfin.Portfolio, summary: zfin.va
|
||||||
state.result = zfin.analysis.analyzePortfolio(
|
state.result = zfin.analysis.analyzePortfolio(
|
||||||
app.allocator,
|
app.allocator,
|
||||||
summary.allocations,
|
summary.allocations,
|
||||||
cm,
|
cm_ptr.*,
|
||||||
pf,
|
pf,
|
||||||
summary.total_value,
|
summary.total_value,
|
||||||
acct_map_opt,
|
acct_map_opt,
|
||||||
|
|
@ -220,7 +192,8 @@ pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]c
|
||||||
total_value = summary.total_value;
|
total_value = summary.total_value;
|
||||||
if (app.portfolio.file) |pf| {
|
if (app.portfolio.file) |pf| {
|
||||||
const benchmark = @import("../analytics/benchmark.zig");
|
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(
|
const split = benchmark.deriveAllocationSplit(
|
||||||
summary.allocations,
|
summary.allocations,
|
||||||
cm_entries,
|
cm_entries,
|
||||||
|
|
@ -529,7 +502,7 @@ test "tab.init produces zero-defaulted state" {
|
||||||
try tab.init(&state, &dummy_app);
|
try tab.init(&state, &dummy_app);
|
||||||
try testing.expectEqual(false, state.loaded);
|
try testing.expectEqual(false, state.loaded);
|
||||||
try testing.expect(state.result == null);
|
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).
|
// Default sector granularity is mid (matches CLI default).
|
||||||
try testing.expectEqual(zfin.analysis.Granularity.mid, state.sector_granularity);
|
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
|
/// Select the cursor row's symbol as the currently-active
|
||||||
/// symbol for the per-symbol tabs (quote/perf/options/etc.).
|
/// symbol for the per-symbol tabs (quote/perf/options/etc.).
|
||||||
select_symbol,
|
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 ─────────────────────────────────────────
|
// ── Tab-private state ─────────────────────────────────────────
|
||||||
|
|
@ -260,6 +265,10 @@ pub const meta: framework.TabMeta(Action) = .{
|
||||||
.{ .action = .clear_account_filter, .key = .{ .codepoint = vaxis.Key.escape } },
|
.{ .action = .clear_account_filter, .key = .{ .codepoint = vaxis.Key.escape } },
|
||||||
.{ .action = .select_symbol, .key = .{ .codepoint = 's' } },
|
.{ .action = .select_symbol, .key = .{ .codepoint = 's' } },
|
||||||
.{ .action = .select_symbol, .key = .{ .codepoint = vaxis.Key.space } },
|
.{ .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(.{
|
.action_labels = std.enums.EnumArray(Action, []const u8).init(.{
|
||||||
.expand_collapse = "Expand/collapse position",
|
.expand_collapse = "Expand/collapse position",
|
||||||
|
|
@ -269,12 +278,14 @@ pub const meta: framework.TabMeta(Action) = .{
|
||||||
.open_account_picker = "Filter by account",
|
.open_account_picker = "Filter by account",
|
||||||
.clear_account_filter = "Clear account filter",
|
.clear_account_filter = "Clear account filter",
|
||||||
.select_symbol = "Select symbol",
|
.select_symbol = "Select symbol",
|
||||||
|
.toggle_overlay = "Show symbol details",
|
||||||
}),
|
}),
|
||||||
.status_hints = &.{
|
.status_hints = &.{
|
||||||
.sort_col_prev,
|
.sort_col_prev,
|
||||||
.sort_col_next,
|
.sort_col_next,
|
||||||
.sort_reverse,
|
.sort_reverse,
|
||||||
.open_account_picker,
|
.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";
|
const msg = std.fmt.bufPrint(&tmp_buf, "Active: {s}", .{row.symbol}) catch "Active";
|
||||||
app.setStatus(msg);
|
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
|
/// sector-level findings. The latter case is harmless — the
|
||||||
/// active symbol just doesn't change anything per-tab.
|
/// active symbol just doesn't change anything per-tab.
|
||||||
select_symbol,
|
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
|
/// Modal sub-state. `.normal` lets keystrokes flow through the
|
||||||
|
|
@ -96,10 +104,6 @@ pub const State = struct {
|
||||||
loaded: bool = false,
|
loaded: bool = false,
|
||||||
/// Computed view. Owned by State; freed in `deinit` and `reload`.
|
/// Computed view. Owned by State; freed in `deinit` and `reload`.
|
||||||
view: ?review_view.ReviewView = null,
|
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
|
/// Active sort field. Default `.sector` (asc) provides the
|
||||||
/// "grouped by sector with symbol-asc tiebreaker" entry state
|
/// "grouped by sector with symbol-asc tiebreaker" entry state
|
||||||
/// — see `views/review.sortRows` for why the sector column
|
/// — 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.
|
// Mirrors portfolio_tab so muscle memory transfers.
|
||||||
.{ .action = .select_symbol, .key = .{ .codepoint = 's' } },
|
.{ .action = .select_symbol, .key = .{ .codepoint = 's' } },
|
||||||
.{ .action = .select_symbol, .key = .{ .codepoint = vaxis.Key.space } },
|
.{ .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(.{
|
.action_labels = std.enums.EnumArray(Action, []const u8).init(.{
|
||||||
.sort_col_next = "Sort: next column",
|
.sort_col_next = "Sort: next column",
|
||||||
|
|
@ -203,12 +210,14 @@ pub const meta: framework.TabMeta(Action) = .{
|
||||||
.unack = "Un-acknowledge finding",
|
.unack = "Un-acknowledge finding",
|
||||||
.toggle_show_acked = "Toggle show acked findings",
|
.toggle_show_acked = "Toggle show acked findings",
|
||||||
.select_symbol = "Select symbol",
|
.select_symbol = "Select symbol",
|
||||||
|
.toggle_overlay = "Show symbol details",
|
||||||
}),
|
}),
|
||||||
.status_hints = &.{
|
.status_hints = &.{
|
||||||
.ack,
|
.ack,
|
||||||
.unack,
|
.unack,
|
||||||
.toggle_show_acked,
|
.toggle_show_acked,
|
||||||
.select_symbol,
|
.select_symbol,
|
||||||
|
.toggle_overlay,
|
||||||
.sort_col_prev,
|
.sort_col_prev,
|
||||||
.sort_col_next,
|
.sort_col_next,
|
||||||
},
|
},
|
||||||
|
|
@ -254,20 +263,19 @@ pub const tab = struct {
|
||||||
pub const deactivate = framework.noopDeactivate(State);
|
pub const deactivate = framework.noopDeactivate(State);
|
||||||
|
|
||||||
/// Manual refresh: drops the cached view and re-builds. Also
|
/// Manual refresh: drops the cached view and re-builds. Also
|
||||||
/// clears the dividend cache (in case new dividends arrived) and
|
/// invalidates the shared `account_map` and `classification_map`
|
||||||
/// the shared `account_map` so accounts.srf gets re-read. The
|
/// on PortfolioData so the next load re-reads `accounts.srf` /
|
||||||
/// classification_map persists — it's per-portfolio, not
|
/// `metadata.srf` from disk (the user may have edited either).
|
||||||
/// per-refresh. The journal is reloaded too in case the user
|
/// The journal is reloaded too in case the user edited
|
||||||
/// edited acknowledgments.srf out-of-band.
|
/// acknowledgments.srf out-of-band.
|
||||||
pub fn reload(state: *State, app: *App) !void {
|
pub fn reload(state: *State, app: *App) !void {
|
||||||
if (state.view) |*v| v.deinit(app.allocator);
|
if (state.view) |*v| v.deinit(app.allocator);
|
||||||
state.view = null;
|
state.view = null;
|
||||||
if (state.findings_view) |*fv| fv.deinit(app.allocator);
|
if (state.findings_view) |*fv| fv.deinit(app.allocator);
|
||||||
state.findings_view = null;
|
state.findings_view = null;
|
||||||
state.loaded = false;
|
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.invalidateAccountMap();
|
||||||
|
app.portfolio.invalidateClassificationMap();
|
||||||
loadJournal(state, app);
|
loadJournal(state, app);
|
||||||
loadData(state, app);
|
loadData(state, app);
|
||||||
}
|
}
|
||||||
|
|
@ -306,6 +314,7 @@ pub const tab = struct {
|
||||||
rebuildFindingsView(state, app);
|
rebuildFindingsView(state, app);
|
||||||
},
|
},
|
||||||
.select_symbol => selectSymbolAtCursor(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);
|
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.
|
/// Free `state.note_fragments` items + the slice. Idempotent.
|
||||||
fn clearNoteFragments(state: *State, allocator: std.mem.Allocator) void {
|
fn clearNoteFragments(state: *State, allocator: std.mem.Allocator) void {
|
||||||
for (state.note_fragments.items) |frag| allocator.free(frag);
|
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 pf = app.portfolio.file orelse return;
|
||||||
const summary_ptr = if (app.portfolio.summary) |*s| s else 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.
|
// Tier 1 accessors block on their respective worker futures.
|
||||||
// Review needs candles, dividends, and the account map; this
|
// Review needs candles, dividends, classification, and the
|
||||||
// is the first tab where all three workers' costs may show
|
// account map; this is the first tab where all four workers'
|
||||||
// up as visible wait. Most users will hit portfolio first
|
// costs may show up as visible wait. Most users will hit
|
||||||
// (where snapshots blocks if needed) before navigating to
|
// portfolio first (where snapshots blocks if needed) before
|
||||||
// review, so the candles + dividends workers are usually
|
// navigating to review, so the candles + dividends workers
|
||||||
// done by the time we get here.
|
// are usually done by the time we get here.
|
||||||
const candle_map = app.portfolio.candles() orelse {
|
const candle_map = app.portfolio.candles() orelse {
|
||||||
app.setStatus("Portfolio data not loaded");
|
app.setStatus("Portfolio data not loaded");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
const dividend_map = app.portfolio.dividends();
|
const dividend_map = app.portfolio.dividends();
|
||||||
const acct_map_opt: ?zfin.analysis.AccountMap = if (app.portfolio.accountMap()) |amp| amp.* else null;
|
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);
|
if (state.view) |*v| v.deinit(app.allocator);
|
||||||
state.view = review_view.buildReview(
|
state.view = review_view.buildReview(
|
||||||
|
|
@ -920,7 +938,7 @@ fn loadData(state: *State, app: *App) void {
|
||||||
candle_map,
|
candle_map,
|
||||||
dividend_map,
|
dividend_map,
|
||||||
pf,
|
pf,
|
||||||
state.classification_map orelse return,
|
cm_ptr.*,
|
||||||
acct_map_opt,
|
acct_map_opt,
|
||||||
app.today,
|
app.today,
|
||||||
app.anchorPath() orelse "",
|
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.view) |*v| v.deinit(allocator);
|
||||||
if (state.findings_view) |*fv| fv.deinit(allocator);
|
if (state.findings_view) |*fv| fv.deinit(allocator);
|
||||||
if (state.journal) |*j| j.deinit();
|
if (state.journal) |*j| j.deinit();
|
||||||
if (state.classification_map) |*cm| cm.deinit();
|
|
||||||
for (state.note_fragments.items) |frag| allocator.free(frag);
|
for (state.note_fragments.items) |frag| allocator.free(frag);
|
||||||
state.note_fragments.deinit(allocator);
|
state.note_fragments.deinit(allocator);
|
||||||
state.* = .{};
|
state.* = .{};
|
||||||
|
|
@ -2307,7 +2324,6 @@ test "deinitState: cleans up view (leak check)" {
|
||||||
var state: State = .{
|
var state: State = .{
|
||||||
.loaded = true,
|
.loaded = true,
|
||||||
.view = view,
|
.view = view,
|
||||||
.classification_map = null, // owned by caller in this test
|
|
||||||
.sort_field = .sector,
|
.sort_field = .sector,
|
||||||
.sort_dir = .asc,
|
.sort_dir = .asc,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue