migrate history tab to new framework

This commit is contained in:
Emil Lerch 2026-05-14 18:29:37 -07:00
parent d4d8961eff
commit 61dd86dc9a
Signed by: lobo
GPG key ID: A7B62D657EF764F8
5 changed files with 401 additions and 259 deletions

View file

@ -22,11 +22,6 @@ const earnings_tab = @import("tui/earnings_tab.zig");
const analysis_tab = @import("tui/analysis_tab.zig");
const history_tab = @import("tui/history_tab.zig");
const projections_tab = @import("tui/projections_tab.zig");
const history = @import("history.zig");
const timeline = @import("analytics/timeline.zig");
const compare_core = @import("compare.zig");
const compare_view = @import("views/compare.zig");
/// Comptime-generated table of single-character grapheme slices with static lifetime.
/// This avoids dangling pointers from stack-allocated temporaries in draw functions.
const ascii_g = blk: {
@ -134,32 +129,6 @@ pub const StyledLine = struct {
cell_styles: ?[]const vaxis.Style = null,
};
/// Backing resources for the history tab's active compare view.
///
/// Both endpoints are tracked independently. Snapshot endpoints own
/// their `SnapshotSide` (which includes the snapshot bytes the
/// HoldingMap keys borrow from). Live endpoints own only a
/// `HoldingMap`; the map's keys borrow from `App.portfolio`, which
/// outlives this struct.
///
/// Deinit order is important: the `CompareView` must be deinit'd
/// before these resources, because the view's `symbols` slice contains
/// `SymbolChange.symbol` strings that borrow from one of the maps
/// (the "then" side, per `buildCompareView`).
pub const HistoryCompareResources = struct {
then_snap: ?compare_core.SnapshotSide = null,
now_snap: ?compare_core.SnapshotSide = null,
then_live_map: ?compare_view.HoldingMap = null,
now_live_map: ?compare_view.HoldingMap = null,
pub fn deinit(self: *HistoryCompareResources, allocator: std.mem.Allocator) void {
if (self.then_snap) |*s| s.deinit(allocator);
if (self.now_snap) |*s| s.deinit(allocator);
if (self.then_live_map) |*m| m.deinit();
if (self.now_live_map) |*m| m.deinit();
}
};
// Tab-specific types
// These logically belong to individual tab files, but live here because
// App's struct fields reference them and Zig requires field types to be
@ -318,6 +287,7 @@ pub const TabStates = struct {
quote: quote_tab.State = .{},
performance: performance_tab.State = .{},
options: options_tab.State = .{},
history: history_tab.State = .{},
};
/// Comptime registry of all tab modules conforming to the
@ -354,6 +324,7 @@ const tab_modules = .{
.quote = quote_tab,
.performance = performance_tab,
.options = options_tab,
.history = history_tab,
};
comptime {
@ -604,33 +575,7 @@ pub const App = struct {
account_search_matches: std.ArrayList(usize) = .empty, // indices into account_list matching search
account_search_cursor: usize = 0, // cursor within search_matches
// History tab state
history_loaded: bool = false,
history_disabled: bool = false, // true when no portfolio path (history requires it)
history_timeline: ?history.LoadedTimeline = null,
// Cursor for the recent-snapshots table. 0 = newest row (live
// pseudo-row if available, otherwise newest snapshot).
history_cursor: usize = 0,
// Up to two rows marked for comparison via `compare_select`
// (default 's' / space). Entries are indices into the displayed
// table. `null` slots mean "no selection". Fixed-size array pins
// the cap at type level.
history_selections: [2]?usize = .{ null, null },
// Active compare view. When non-null, the history tab renders
// compare output instead of the timeline. Cleared by
// `compare_cancel` (default Esc) or toggling compare_commit again.
history_compare_view: ?compare_view.CompareView = null,
// Resources backing `history_compare_view` owned by the App so
// their lifetime matches the view's. Cleared together with the
// view.
history_compare_resources: ?HistoryCompareResources = null,
// First line-number where the recent-snapshots table body starts.
// Set during `history_tab.buildStyledLines`; consumed by the key
// handler's ensure-cursor-visible logic.
history_table_first_line: usize = 0,
// Number of rows currently rendered in the table (including the
// live pseudo-row when present). Used for cursor clamping.
history_table_row_count: usize = 0,
// History tab state lives in `self.states.history` (see TabStates).
// Projections tab state
projections_loaded: bool = false,
@ -663,23 +608,6 @@ pub const App = struct {
/// meaningful when `projections_as_of` is set; the keybind
/// flashes a status message and leaves this off otherwise.
projections_overlay_actuals: bool = false,
// Default to `.liquid` that's the metric most worth watching
// day-to-day. Illiquid barely changes, net_worth is dominated by
// liquid anyway, so "show me liquid" is the headline view.
history_metric: timeline.Metric = .liquid,
/// Forced resolution for the history table + chart. Null means
/// "default" interpreted as cascading by the renderer. Cycled
/// via the `history_resolution_next` keybind ('t' by default).
history_resolution: ?timeline.Resolution = null,
/// Buckets that the user has explicitly expanded in the
/// cascading-view recent-snapshots table. Keyed by
/// `(tier, bucket_start.days)` so that a parent and its
/// edge-aligned child (e.g. yearly 2024 starts on the same
/// day as quarterly Q1 2024) are distinct.
/// Default: empty every bucket is collapsed at the
/// drilldown level. Daily rows for the last 14 days are
/// always shown inline.
history_expanded_buckets: std.AutoHashMap(history_tab.BucketKey, void) = undefined,
// Mouse wheel debounce for cursor-based tabs (portfolio, options).
// Terminals often send multiple wheel events per physical tick.
@ -786,7 +714,7 @@ pub const App = struct {
for (tabs) |t| {
const lbl_len: i16 = @intCast(t.label().len);
if (mouse.col >= col and mouse.col < col + lbl_len) {
if (self.isTabDisabled(t)) return;
if (self.isDisabled(t)) return;
self.active_tab = t;
self.scroll_offset = 0;
self.loadTabData();
@ -847,27 +775,24 @@ pub const App = struct {
}
}
// Framework dispatch: ask the active tab's `handleMouse`
// (when defined) if it wants to consume this click. Tabs
// without a `handleMouse` decl simply fall through.
if (self.dispatchBool("handleMouse", .{mouse})) {
// (when defined) if it wants to consume this click.
//
// Chrome ownership: row 0 is the tab bar clicks
// that hit a tab label were already consumed above;
// misses are dropped here so tab handlers only see
// content-region events. (Future: a tab might want
// to opt in to chrome regions for per-tab indicators
// would require a framework hook to claim chrome
// ranges, not just a row-0 bypass.)
//
// The bottom status row (`max_size.height - 1`) is
// also chrome but isn't filtered yet none of the
// current tabs have content there, so clicks land
// harmlessly. Filter here when a tab grows
// bottom-edge content that needs disambiguation.
if (mouse.row > 0 and self.dispatchBool("handleMouse", .{mouse})) {
return ctx.consumeAndRedraw();
}
// History tab: click a tier header to expand/collapse;
// click a bucket/snapshot row to move the cursor.
if (self.active_tab == .history and self.history_compare_view == null and mouse.row > 0) {
const content_row = @as(usize, @intCast(mouse.row)) + self.scroll_offset;
if (content_row >= self.history_table_first_line and self.history_table_row_count > 0) {
const row_idx = content_row - self.history_table_first_line;
if (row_idx < self.history_table_row_count) {
// Move the cursor to the clicked row, then
// try to toggle if it's a tier header. Both
// outcomes consume the click and redraw.
self.history_cursor = row_idx;
_ = history_tab.toggleTierAtCursor(self);
return ctx.consumeAndRedraw();
}
}
}
},
else => {},
}
@ -1363,7 +1288,7 @@ pub const App = struct {
// silently consume) these keys. This intercept runs first when
// the user is in the history tab so compare behavior wins.
if (self.active_tab == .history) {
if (history_tab.handleCompareKey(self, ctx, key)) return;
if (history_tab.handleCompareKey(&self.states.history, self, ctx, key)) return;
}
// Escape: clear account filter on portfolio tab, clear as-of
@ -1434,7 +1359,7 @@ pub const App = struct {
const idx = @intFromEnum(action) - @intFromEnum(keybinds.Action.tab_1);
if (idx < tabs.len) {
const target = tabs[idx];
if (self.isTabDisabled(target)) return;
if (self.isDisabled(target)) return;
self.active_tab = target;
self.scroll_offset = 0;
self.loadTabData();
@ -1458,9 +1383,8 @@ pub const App = struct {
options_tab.tab.handleAction(&self.states.options, self, options_tab.Action.expand_collapse);
return ctx.consumeAndRedraw();
} else if (self.active_tab == .history) {
if (history_tab.toggleTierAtCursor(self)) {
return ctx.consumeAndRedraw();
}
history_tab.tab.handleAction(&self.states.history, self, history_tab.Action.expand_collapse);
return ctx.consumeAndRedraw();
}
},
.scroll_down => {
@ -1543,13 +1467,13 @@ pub const App = struct {
},
.history_metric_next => {
if (self.active_tab == .history) {
history_tab.cycleMetric(self);
history_tab.tab.handleAction(&self.states.history, self, history_tab.Action.metric_next);
return ctx.consumeAndRedraw();
}
},
.history_resolution_next => {
if (self.active_tab == .history) {
history_tab.cycleResolution(self);
history_tab.tab.handleAction(&self.states.history, self, history_tab.Action.resolution_next);
return ctx.consumeAndRedraw();
}
},
@ -1667,24 +1591,28 @@ pub const App = struct {
// and so future user-supplied keybindings targeting these
// action names work correctly.
.compare_select => {
if (self.active_tab == .history and self.history_compare_view == null and self.history_table_row_count > 0) {
history_tab.toggleSelectionAt(self, self.history_cursor);
return ctx.consumeAndRedraw();
if (self.active_tab == .history) {
const hs = &self.states.history;
if (hs.compare_view == null and hs.table_row_count > 0) {
history_tab.toggleSelectionAt(hs, self, hs.cursor);
return ctx.consumeAndRedraw();
}
}
},
.compare_commit => {
if (self.active_tab == .history) {
if (self.history_compare_view != null) {
history_tab.clearCompareState(self);
const hs = &self.states.history;
if (hs.compare_view != null) {
history_tab.clearCompareState(hs, self);
} else {
history_tab.commitCompareExternal(self);
history_tab.commitCompareExternal(hs, self);
}
return ctx.consumeAndRedraw();
}
},
.compare_cancel => {
if (self.active_tab == .history) {
history_tab.clearCompareState(self);
history_tab.clearCompareState(&self.states.history, self);
return ctx.consumeAndRedraw();
}
},
@ -1708,22 +1636,16 @@ pub const App = struct {
/// For other tabs (or cursor-bearing tabs with empty rows),
/// adjusts scroll_offset by |n|.
fn moveBy(self: *App, n: isize) void {
// Unmigrated cursor-bearing tabs (portfolio + history).
// Their cursor state still lives on App; once migrated,
// both will move into onCursorMove hooks like options.
// Unmigrated cursor-bearing tab (portfolio).
// Its cursor state still lives on App; once migrated, this
// branch goes into an onCursorMove hook like options/history.
if (self.active_tab == .portfolio) {
if (self.shouldDebounceWheel()) return;
stepCursor(&self.cursor, self.portfolio_rows.items.len, n);
self.ensureCursorVisible();
return;
}
if (self.active_tab == .history and self.history_compare_view == null and self.history_table_row_count > 0) {
if (self.shouldDebounceWheel()) return;
stepCursor(&self.history_cursor, self.history_table_row_count, n);
self.ensureHistoryCursorVisible();
return;
}
// Migrated cursor-bearing tabs (currently: options). The
// Migrated cursor-bearing tabs (options, history). The
// hook returns false when it has no rows, so we fall
// through to scroll. Debounce applies to the cursor-move
// path only preserving legacy behavior where wheel
@ -1762,20 +1684,6 @@ pub const App = struct {
}
}
/// Scroll so that the history-tab cursor row is visible. Uses the
/// `history_table_first_line` metadata stashed during the most
/// recent render; safe when it's zero (initial state) because the
/// cursor is also zero then.
pub fn ensureHistoryCursorVisible(self: *App) void {
const cursor_line = self.history_table_first_line + self.history_cursor;
const vis: usize = self.visible_height;
if (cursor_line < self.scroll_offset) {
self.scroll_offset = cursor_line;
} else if (cursor_line >= self.scroll_offset + vis) {
self.scroll_offset = cursor_line - vis + 1;
}
}
fn toggleExpand(self: *App) void {
if (self.portfolio_rows.items.len == 0) return;
if (self.cursor >= self.portfolio_rows.items.len) return;
@ -1870,8 +1778,7 @@ pub const App = struct {
analysis_tab.tab.reload(&self.states.analysis, self) catch {};
},
.history => {
self.history_loaded = false;
history_tab.freeLoaded(self);
history_tab.tab.reload(&self.states.history, self) catch {};
},
.projections => {
self.projections_loaded = false;
@ -1918,8 +1825,7 @@ pub const App = struct {
analysis_tab.tab.activate(&self.states.analysis, self) catch {};
},
.history => {
if (self.history_disabled) return;
if (!self.history_loaded) history_tab.loadData(self);
history_tab.tab.activate(&self.states.history, self) catch {};
},
.projections => {
if (self.projections_disabled) return;
@ -1977,7 +1883,7 @@ pub const App = struct {
if (self.filtered_positions) |fp| self.allocator.free(fp);
analysis_tab.tab.deinit(&self.states.analysis, self);
self.portfolio.deinit(self.allocator);
history_tab.freeLoaded(self);
history_tab.tab.deinit(&self.states.history, self);
projections_tab.freeLoaded(self);
quote_tab.tab.deinit(&self.states.quote, self);
}
@ -2023,7 +1929,7 @@ pub const App = struct {
for (tabs) |t| {
const lbl = t.label();
const is_active = t == self.active_tab;
const is_disabled = self.isTabDisabled(t);
const is_disabled = self.isDisabled(t);
const tab_style: vaxis.Style = if (is_active) th.tabActiveStyle() else if (is_disabled) th.tabDisabledStyle() else inactive_style;
for (lbl) |ch| {
@ -2057,13 +1963,12 @@ pub const App = struct {
/// Whether the given tab should be treated as disabled in
/// the current App context. Migrated tabs are consulted via
/// their framework-contract `isDisabled` hook; unmigrated tabs
/// (history, projections) still use bespoke `_disabled` flags
/// set during App init. The unmigrated branches go away when
/// those tabs adopt the framework the `_disabled` fields
/// will be deleted alongside the inline checks here.
fn isTabDisabled(self: *App, t: Tab) bool {
/// (projections) still use bespoke `_disabled` flags set during
/// App init. The unmigrated branch goes away when projections
/// adopts the framework the `_disabled` field will be deleted
/// alongside the inline check here.
fn isDisabled(self: *App, t: Tab) bool {
return self.appPredicate(t, "isDisabled") or
(t == .history and self.history_disabled) or
(t == .projections and self.projections_disabled);
}
@ -2266,7 +2171,7 @@ pub const App = struct {
}
fn buildHistoryStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
return history_tab.buildStyledLines(self, arena);
return history_tab.buildStyledLines(&self.states.history, self, arena);
}
// Help
@ -2345,7 +2250,7 @@ pub const App = struct {
var next_idx = if (idx + 1 < tabs.len) idx + 1 else 0;
// Skip disabled tabs (earnings for ETFs, analysis without portfolio)
var tries: usize = 0;
while (self.isTabDisabled(tabs[next_idx]) and tries < tabs.len) : (tries += 1)
while (self.isDisabled(tabs[next_idx]) and tries < tabs.len) : (tries += 1)
next_idx = if (next_idx + 1 < tabs.len) next_idx + 1 else 0;
self.active_tab = tabs[next_idx];
}
@ -2355,7 +2260,7 @@ pub const App = struct {
var prev_idx = if (idx > 0) idx - 1 else tabs.len - 1;
// Skip disabled tabs (earnings for ETFs, analysis without portfolio)
var tries: usize = 0;
while (self.isTabDisabled(tabs[prev_idx]) and tries < tabs.len) : (tries += 1)
while (self.isDisabled(tabs[prev_idx]) and tries < tabs.len) : (tries += 1)
prev_idx = if (prev_idx > 0) prev_idx - 1 else tabs.len - 1;
self.active_tab = tabs[prev_idx];
}
@ -2598,9 +2503,11 @@ pub fn run(
.symbol = symbol,
.has_explicit_symbol = has_explicit_symbol,
.chart_config = chart_config,
.history_expanded_buckets = std.AutoHashMap(history_tab.BucketKey, void).init(allocator),
};
defer app_inst.history_expanded_buckets.deinit();
// History tab requires explicit init (allocator-backed hash map);
// other tabs use field defaults. The corresponding deinit lives
// in `App.deinitData`.
try history_tab.tab.init(&app_inst.states.history, app_inst);
if (portfolio_path) |path| {
const file_data = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(10 * 1024 * 1024)) catch null;
@ -2635,15 +2542,11 @@ pub fn run(
// Disable projections tab when no portfolio is loaded.
// Analysis derives the same condition via its `isDisabled`
// method (no field write needed) the predicate is computed
// from `app.portfolio.file == null` directly.
// from `app.portfolio.file == null` directly. History does the
// same see `history_tab.tab.isDisabled`.
if (app_inst.portfolio.file == null) {
app_inst.projections_disabled = true;
}
// History tab also requires a portfolio path to locate the
// history/ subdirectory.
if (app_inst.portfolio_path == null) {
app_inst.history_disabled = true;
}
// Pre-fetch portfolio prices before TUI starts, with stderr progress.
// This runs while the terminal is still in normal mode so output is visible.

View file

@ -45,10 +45,234 @@ const timeline = @import("../analytics/timeline.zig");
const view = @import("../views/history.zig");
const compare_core = @import("../compare.zig");
const compare_view = @import("../views/compare.zig");
const framework = @import("tab_framework.zig");
const App = tui.App;
const StyledLine = tui.StyledLine;
/// Composite key for `App.history_expanded_buckets`. Keying by
// Tab-local action enum
//
// History tab keybinds:
// - `m` : cycle the focused metric (liquid
// illiquid net_worth).
// - `t` : cycle the resolution (cascading
// daily weekly monthly cascading).
// - Enter : expand/collapse the bucket at the cursor
// (only meaningful for non-daily, non-live
// rows with finer-tier breakdown).
//
// `s` / space / `c` / Esc are ALSO history-local (compare-mode
// selection toggle, commit, and cancel) but are still routed via
// the legacy global `keybinds.Action.compare_*` variants and the
// pre-dispatch `handleCompareKey` intercept in tui.zig. Those are
// the next layer of the migration (scoped keymaps, TODO step 3)
// once scoped keymaps land, both the global variants and the
// intercept disappear and these become per-tab actions.
pub const Action = enum {
/// Toggle expansion of the bucket at the cursor. No-op for
/// daily, live, or no-children rows. Bound to Enter.
expand_collapse,
/// Cycle the focused metric: liquid illiquid net_worth liquid.
metric_next,
/// Cycle the resolution: cascading daily weekly monthly cascading.
resolution_next,
};
// Tab-private state
/// Backing resources for the history tab's active compare view.
///
/// Both endpoints are tracked independently. Snapshot endpoints own
/// their `SnapshotSide` (which includes the snapshot bytes the
/// HoldingMap keys borrow from). Live endpoints own only a
/// `HoldingMap`; the map's keys borrow from `App.portfolio`, which
/// outlives this struct.
///
/// Deinit order is important: the `CompareView` must be deinit'd
/// before these resources, because the view's `symbols` slice contains
/// `SymbolChange.symbol` strings that borrow from one of the maps
/// (the "then" side, per `buildCompareView`).
pub const CompareResources = struct {
then_snap: ?compare_core.SnapshotSide = null,
now_snap: ?compare_core.SnapshotSide = null,
then_live_map: ?compare_view.HoldingMap = null,
now_live_map: ?compare_view.HoldingMap = null,
pub fn deinit(self: *CompareResources, allocator: std.mem.Allocator) void {
if (self.then_snap) |*s| s.deinit(allocator);
if (self.now_snap) |*s| s.deinit(allocator);
if (self.then_live_map) |*m| m.deinit();
if (self.now_live_map) |*m| m.deinit();
}
};
pub const State = struct {
/// Whether `activate` has populated the timeline (or set
/// `disabled`). Distinct from `timeline != null` because
/// failed/empty fetches still mark loaded.
loaded: bool = false,
/// Loaded timeline + series (owned). Null when not loaded
/// or when the load failed.
tl: ?history.LoadedTimeline = null,
/// Cursor position in the recent-snapshots table.
cursor: usize = 0,
/// Compare-mode selection slots. Up to two row indices may
/// be selected; commit-compare requires exactly two.
selections: [2]?usize = .{ null, null },
/// Active compare view. When non-null, the history tab
/// renders a `CompareView` instead of the timeline.
compare_view: ?compare_view.CompareView = null,
/// Backing storage for `compare_view`. Owned by State;
/// freed via `clearCompareView`.
compare_resources: ?CompareResources = null,
/// Set during `buildStyledLines`; consumed by the key
/// handler's cursor-visibility logic and click hit-tests.
/// First display line of the recent-snapshots table.
table_first_line: usize = 0,
/// Number of rows in the recent-snapshots table (post-aggregation).
table_row_count: usize = 0,
/// Currently displayed metric (cycles via `metric_next`).
metric: timeline.Metric = .liquid,
/// Forced resolution for the history table + chart. Null means
/// "use the cascading default chosen by `selectResolution`";
/// the user cycles this with `resolution_next` ('t' by default).
resolution: ?timeline.Resolution = null,
/// Per-bucket expansion set. Keyed by `BucketKey` (tier + days)
/// to disambiguate edge-aligned parents and children. Initialized
/// in `init` (requires an allocator).
expanded_buckets: std.AutoHashMap(BucketKey, void) = undefined,
};
// Tab framework contract
pub const tab = struct {
pub const ActionT = Action;
pub const StateT = State;
pub const default_bindings: []const framework.TabBinding(Action) = &.{
.{ .action = .expand_collapse, .key = .{ .codepoint = vaxis.Key.enter } },
.{ .action = .metric_next, .key = .{ .codepoint = 'm' } },
.{ .action = .resolution_next, .key = .{ .codepoint = 't' } },
};
pub const action_labels = std.enums.EnumArray(Action, []const u8).init(.{
.expand_collapse = "Expand/collapse bucket",
.metric_next = "Cycle metric",
.resolution_next = "Cycle resolution",
});
pub const status_hints: []const Action = &.{
.metric_next,
.resolution_next,
};
pub fn init(state: *State, app: *App) !void {
state.* = .{
.expanded_buckets = std.AutoHashMap(BucketKey, void).init(app.allocator),
};
}
pub fn deinit(state: *State, app: *App) void {
freeLoaded(state, app);
state.expanded_buckets.deinit();
state.* = .{};
}
pub fn activate(state: *State, app: *App) !void {
if (state.loaded) return;
loadData(state, app);
}
pub const deactivate = framework.noopDeactivate(State);
pub fn reload(state: *State, app: *App) !void {
state.loaded = false;
freeLoaded(state, app);
loadData(state, app);
}
pub const tick = framework.noopTick(State);
pub fn handleAction(state: *State, app: *App, action: Action) void {
switch (action) {
.expand_collapse => _ = toggleTierAtCursor(state, app),
.metric_next => cycleMetric(state),
.resolution_next => cycleResolution(state),
}
}
/// History is disabled when no portfolio is loaded the
/// history/ subdirectory is derived from the portfolio path.
/// Same predicate as analysis_tab and projections_tab.
pub fn isDisabled(app: *App) bool {
return app.portfolio.file == null;
}
/// Sync the cursor to the new scroll extreme.
pub fn onScroll(state: *State, app: *App, where: framework.ScrollEdge) void {
_ = app;
switch (where) {
.top => state.cursor = 0,
.bottom => {
if (state.table_row_count > 0) {
state.cursor = state.table_row_count - 1;
}
},
}
}
/// Cursor navigation in the recent-snapshots table. Disabled
/// during compare view mode (Esc/`c` returns first); also no-op
/// when the table is empty (returns false so the framework
/// falls through to viewport scroll).
pub fn onCursorMove(state: *State, app: *App, delta: isize) bool {
if (state.compare_view != null) return false;
if (state.table_row_count == 0) return false;
stepCursor(&state.cursor, state.table_row_count, delta);
ensureCursorVisible(state, &app.scroll_offset, app.visible_height);
return true;
}
/// Mouse handling: a left-click on a row moves the cursor and
/// toggles tier expansion (no-op for non-tier rows). Returns
/// `true` if the click landed on a data row in the recent-
/// snapshots table; `false` otherwise (caller falls through
/// to global mouse handling wheel scroll, tab-bar clicks,
/// etc.). Disabled during compare-view mode.
pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool {
if (mouse.button != .left) return false;
if (mouse.type != .press) return false;
if (state.compare_view != null) return false;
const content_row = @as(usize, @intCast(mouse.row)) + app.scroll_offset;
if (content_row < state.table_first_line) return false;
if (state.table_row_count == 0) return false;
const row_idx = content_row - state.table_first_line;
if (row_idx >= state.table_row_count) return false;
// Move the cursor to the clicked row, then try to toggle
// if it's a tier header. Both outcomes consume the click.
state.cursor = row_idx;
_ = toggleTierAtCursor(state, app);
return true;
}
};
fn stepCursor(cursor: *usize, row_count: usize, direction: isize) void {
if (direction > 0) {
if (row_count > 0 and cursor.* < row_count - 1) cursor.* += 1;
} else {
if (cursor.* > 0) cursor.* -= 1;
}
}
fn ensureCursorVisible(state: *const State, scroll_offset: *usize, visible_height: usize) void {
const cursor_line = state.table_first_line + state.cursor;
if (cursor_line < scroll_offset.*) scroll_offset.* = cursor_line;
if (cursor_line >= scroll_offset.* + visible_height) {
scroll_offset.* = cursor_line - visible_height + 1;
}
}
/// Composite key for `State.expanded_buckets`. Keying by
/// `bucket_start.days` alone collides on edge-aligned parents
/// and children e.g. yearly 2024 starts on 2024-01-01, and so
/// does its child quarterly Q1 2024. Tagging by tier
@ -71,50 +295,50 @@ fn keyForRow(row: TableRow) ?BucketKey {
// Data loading
pub fn loadData(app: *App) void {
app.history_loaded = true;
freeLoaded(app);
pub fn loadData(state: *State, app: *App) void {
state.loaded = true;
freeLoaded(state, app);
const portfolio_path = app.portfolio_path orelse {
app.setStatus("History tab requires a loaded portfolio");
return;
};
app.history_timeline = history.loadTimeline(app.io, app.allocator, portfolio_path) catch {
state.tl = history.loadTimeline(app.io, app.allocator, portfolio_path) catch {
app.setStatus("Failed to read history/ directory");
return;
};
if (app.history_timeline.?.loaded.snapshots.len == 0) {
freeLoaded(app);
if (state.tl.?.loaded.snapshots.len == 0) {
freeLoaded(state, app);
app.setStatus("No snapshots in history/ (run: zfin snapshot)");
}
}
/// Release the loaded timeline (if any).
pub fn freeLoaded(app: *App) void {
if (app.history_timeline) |*tl| {
pub fn freeLoaded(state: *State, app: *App) void {
if (state.tl) |*tl| {
tl.deinit();
app.history_timeline = null;
state.tl = null;
}
clearCompareView(app);
clearCompareView(state, app);
}
/// Clear the compare-view state (selections are preserved).
fn clearCompareView(app: *App) void {
if (app.history_compare_view) |*cv| {
fn clearCompareView(state: *State, app: *App) void {
if (state.compare_view) |*cv| {
cv.deinit(app.allocator);
app.history_compare_view = null;
state.compare_view = null;
}
if (app.history_compare_resources) |*res| {
if (state.compare_resources) |*res| {
res.deinit(app.allocator);
app.history_compare_resources = null;
state.compare_resources = null;
}
}
/// Cycle the displayed metric: liquid illiquid net_worth liquid.
pub fn cycleMetric(app: *App) void {
app.history_metric = switch (app.history_metric) {
pub fn cycleMetric(state: *State) void {
state.metric = switch (state.metric) {
.liquid => .illiquid,
.illiquid => .net_worth,
.net_worth => .liquid,
@ -122,10 +346,10 @@ pub fn cycleMetric(app: *App) void {
}
/// Cycle resolution: cascading daily weekly monthly cascading.
pub fn cycleResolution(app: *App) void {
app.history_resolution = switch (app.history_resolution orelse {
pub fn cycleResolution(state: *State) void {
state.resolution = switch (state.resolution orelse {
// null means "default" i.e. cascading. Step to daily.
app.history_resolution = .daily;
state.resolution = .daily;
return;
}) {
.cascading => .daily,
@ -139,24 +363,24 @@ pub fn cycleResolution(app: *App) void {
/// bucket row, toggle its expanded state. Returns true if a
/// toggle happened (so the caller knows to consume the keypress
/// and trigger a redraw).
pub fn toggleTierAtCursor(app: *App) bool {
pub fn toggleTierAtCursor(state: *State, app: *App) bool {
var arena_state = std.heap.ArenaAllocator.init(app.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const rows = collectTableRows(arena, app) catch return false;
if (app.history_cursor >= rows.len) return false;
const row = rows[app.history_cursor];
const rows = collectTableRows(arena, state, app) catch return false;
if (state.cursor >= rows.len) return false;
const row = rows[state.cursor];
// Only buckets with a finer tier are expandable. Live rows,
// daily rows, and rows without bucket_start are not.
if (!row.has_children) return false;
const key = keyForRow(row) orelse return false;
if (app.history_expanded_buckets.contains(key)) {
_ = app.history_expanded_buckets.remove(key);
if (state.expanded_buckets.contains(key)) {
_ = state.expanded_buckets.remove(key);
} else {
app.history_expanded_buckets.put(key, {}) catch return false;
state.expanded_buckets.put(key, {}) catch return false;
}
return true;
}
@ -164,17 +388,17 @@ pub fn toggleTierAtCursor(app: *App) bool {
// Compare selection model
/// Returns the number of currently selected rows (0, 1, or 2).
fn selectionCount(app: *const App) usize {
fn selectionCount(state: *const State) usize {
var n: usize = 0;
for (app.history_selections) |s| {
for (state.selections) |s| {
if (s != null) n += 1;
}
return n;
}
/// Returns true if row `idx` is currently selected.
fn isSelected(app: *const App, idx: usize) bool {
for (app.history_selections) |s| {
fn isSelected(state: *const State, idx: usize) bool {
for (state.selections) |s| {
if (s) |v| if (v == idx) return true;
}
return false;
@ -182,22 +406,22 @@ fn isSelected(app: *const App, idx: usize) bool {
/// Toggle selection of row `idx`. With two already selected and this
/// row isn't one of them, sets a status hint and leaves state alone.
fn toggleSelection(app: *App, idx: usize) void {
fn toggleSelection(state: *State, app: *App, idx: usize) void {
// Already selected? Remove.
for (&app.history_selections) |*slot| {
for (&state.selections) |*slot| {
if (slot.*) |v| {
if (v == idx) {
slot.* = null;
setSelectionStatus(app);
setSelectionStatus(state, app);
return;
}
}
}
// Find an empty slot.
for (&app.history_selections) |*slot| {
for (&state.selections) |*slot| {
if (slot.* == null) {
slot.* = idx;
setSelectionStatus(app);
setSelectionStatus(state, app);
return;
}
}
@ -206,12 +430,12 @@ fn toggleSelection(app: *App, idx: usize) void {
}
/// Clear all selections.
fn clearSelections(app: *App) void {
app.history_selections = .{ null, null };
fn clearSelections(state: *State) void {
state.selections = .{ null, null };
}
fn setSelectionStatus(app: *App) void {
const n = selectionCount(app);
fn setSelectionStatus(state: *const State, app: *App) void {
const n = selectionCount(state);
switch (n) {
0 => app.setStatus(""),
1 => app.setStatus("Selected 1 row — select one more + press 'c' to compare"),
@ -223,20 +447,20 @@ fn setSelectionStatus(app: *App) void {
/// Public entry for the `compare_select` action dispatched via
/// matchAction. Internally delegates to the same toggle function the
/// intercept uses.
pub fn toggleSelectionAt(app: *App, idx: usize) void {
toggleSelection(app, idx);
pub fn toggleSelectionAt(state: *State, app: *App, idx: usize) void {
toggleSelection(state, app, idx);
}
/// Public entry for the `compare_commit` action.
pub fn commitCompareExternal(app: *App) void {
commitCompare(app);
pub fn commitCompareExternal(state: *State, app: *App) void {
commitCompare(state, app);
}
/// Public entry for the `compare_cancel` action or internal Esc path:
/// clear selections and any active compare view.
pub fn clearCompareState(app: *App) void {
clearCompareView(app);
clearSelections(app);
pub fn clearCompareState(state: *State, app: *App) void {
clearCompareView(state, app);
clearSelections(state);
app.setStatus("");
}
@ -244,8 +468,8 @@ pub fn clearCompareState(app: *App) void {
/// Attempt to run compare. Called when the user presses `c`.
/// No-ops (with status hint) if the selection set isn't exactly 2.
fn commitCompare(app: *App) void {
const sel_count = selectionCount(app);
fn commitCompare(state: *State, app: *App) void {
const sel_count = selectionCount(state);
if (sel_count < 2) {
if (sel_count == 0) {
app.setStatus("Select two rows with 's' (or space), then press 'c' to compare");
@ -256,15 +480,15 @@ fn commitCompare(app: *App) void {
}
// At this point both slots are filled.
const sel_a = app.history_selections[0].?;
const sel_b = app.history_selections[1].?;
const sel_a = state.selections[0].?;
const sel_b = state.selections[1].?;
if (sel_a == sel_b) {
// Shouldn't happen via toggle logic, but guard anyway.
app.setStatus("Selected rows are the same — clear one and reselect");
return;
}
buildCompareFromSelections(app, sel_a, sel_b) catch |err| {
buildCompareFromSelections(state, app, sel_a, sel_b) catch |err| {
var msg_buf: [128]u8 = undefined;
// Translate the most common failure modes into actionable
// messages. We already short-circuit imported-only rows
@ -277,12 +501,12 @@ fn commitCompare(app: *App) void {
else => std.fmt.bufPrint(&msg_buf, "Compare failed: {s}", .{@errorName(err)}) catch "Compare failed",
};
app.setStatus(msg);
clearCompareView(app);
clearCompareView(state, app);
};
}
/// Build + stash the compare view from two selected row indices.
fn buildCompareFromSelections(app: *App, sel_a: usize, sel_b: usize) !void {
fn buildCompareFromSelections(state: *State, app: *App, sel_a: usize, sel_b: usize) !void {
// Resolve each row-index into its date and source (live vs
// snapshot). This requires re-computing the table row list the
// way the renderer does shared helper keeps the two paths in
@ -291,10 +515,10 @@ fn buildCompareFromSelections(app: *App, sel_a: usize, sel_b: usize) !void {
defer arena_state.deinit();
const arena = arena_state.allocator();
const rows = try collectTableRows(arena, app);
const rows = try collectTableRows(arena, state, app);
if (rows.len == 0 or sel_a >= rows.len or sel_b >= rows.len) {
app.setStatus("Stale selection — please re-select");
clearSelections(app);
clearSelections(state);
return;
}
const row_a = rows[sel_a];
@ -311,12 +535,12 @@ fn buildCompareFromSelections(app: *App, sel_a: usize, sel_b: usize) !void {
// status before we try (and fail) to open the snapshot file.
if (older.imported_only or newer.imported_only) {
app.setStatus("Cannot compare: imported-only history has no per-symbol detail");
clearSelections(app);
clearSelections(state);
return;
}
// Build up the resources + maps for each side.
var resources: tui.HistoryCompareResources = .{};
var resources: CompareResources = .{};
errdefer resources.deinit(app.allocator);
const portfolio_path = app.portfolio_path orelse {
@ -401,11 +625,11 @@ fn buildCompareFromSelections(app: *App, sel_a: usize, sel_b: usize) !void {
newer.is_live,
);
// Commit: install both onto the App. From this point onwards the
// App owns them and clearCompareView handles teardown.
clearCompareView(app);
app.history_compare_view = cv_with_labels;
app.history_compare_resources = resources;
// Commit: install both onto State. From this point onwards State
// owns them and clearCompareView handles teardown.
clearCompareView(state, app);
state.compare_view = cv_with_labels;
state.compare_resources = resources;
app.setStatus("Comparing — Esc or 'c' to return to timeline");
}
@ -440,18 +664,25 @@ fn aggregateFromSummary(
/// Intercepted before `matchAction` runs when the active tab is
/// history. Returns true if the key was consumed.
pub fn handleCompareKey(app: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) bool {
///
/// TODO(scoped-keymaps): once scoped keymaps land (TODO step 3),
/// this intercept goes away `s`/space/`c`/Esc become
/// per-tab keybinds bound to local Action variants in
/// `default_bindings`, and the global keymap stops binding them.
/// The pre-dispatch interception in `tui.zig` (the call to
/// `handleCompareKey`) gets deleted alongside.
pub fn handleCompareKey(state: *State, app: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) bool {
// Escape: exit compare view, or clear selections.
if (key.codepoint == vaxis.Key.escape) {
if (app.history_compare_view != null) {
clearCompareView(app);
clearSelections(app);
if (state.compare_view != null) {
clearCompareView(state, app);
clearSelections(state);
app.setStatus("");
ctx.consumeAndRedraw();
return true;
}
if (selectionCount(app) > 0) {
clearSelections(app);
if (selectionCount(state) > 0) {
clearSelections(state);
app.setStatus("");
ctx.consumeAndRedraw();
return true;
@ -462,23 +693,23 @@ pub fn handleCompareKey(app: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key
// 's' or space: toggle selection on the cursor row.
if (key.matches('s', .{}) or key.matches(vaxis.Key.space, .{})) {
// Disabled while the compare view is up Esc to return first.
if (app.history_compare_view != null) return false;
if (app.history_table_row_count == 0) return false;
toggleSelection(app, app.history_cursor);
if (state.compare_view != null) return false;
if (state.table_row_count == 0) return false;
toggleSelection(state, app, state.cursor);
ctx.consumeAndRedraw();
return true;
}
// 'c': commit compare, or exit compare view if already active.
if (key.matches('c', .{})) {
if (app.history_compare_view != null) {
clearCompareView(app);
clearSelections(app);
if (state.compare_view != null) {
clearCompareView(state, app);
clearSelections(state);
app.setStatus("");
ctx.consumeAndRedraw();
return true;
}
commitCompare(app);
commitCompare(state, app);
ctx.consumeAndRedraw();
return true;
}
@ -554,8 +785,8 @@ pub const TierHeader = struct {
/// rows from newest to oldest. The `RowDelta` slice from
/// `computeRowDeltas` is oldest-first; we reverse it and optionally
/// prepend the live row.
pub fn collectTableRows(arena: std.mem.Allocator, app: *const App) ![]TableRow {
const timeline_opt = app.history_timeline;
pub fn collectTableRows(arena: std.mem.Allocator, state: *const State, app: *const App) ![]TableRow {
const timeline_opt = state.tl;
if (timeline_opt == null) return &.{};
const series = timeline_opt.?.series;
if (series.points.len == 0) return &.{};
@ -564,13 +795,13 @@ pub fn collectTableRows(arena: std.mem.Allocator, app: *const App) ![]TableRow {
// - explicit non-cascading: use the legacy flat-aggregation
// path (preserves existing behavior for daily/weekly/monthly).
// - explicit cascading OR null (default): build the tiered view.
const explicit = app.history_resolution;
const explicit = state.resolution;
const use_cascading = explicit == null or explicit.? == .cascading;
if (!use_cascading) {
return collectFlatTableRows(arena, app, series, explicit.?);
}
return collectCascadingTableRows(arena, app, series);
return collectCascadingTableRows(arena, state, app, series);
}
/// Legacy path: flat aggregation by single resolution. Preserved
@ -623,6 +854,7 @@ fn collectFlatTableRows(
/// bucket they're already at leaf granularity).
fn collectCascadingTableRows(
arena: std.mem.Allocator,
state: *const State,
app: *const App,
series: timeline.TimelineSeries,
) ![]TableRow {
@ -642,7 +874,7 @@ fn collectCascadingTableRows(
for (ts.buckets, 0..) |b, idx| {
const d = top_deltas[idx];
const expanded = app.history_expanded_buckets.contains(keyFor(b));
const expanded = state.expanded_buckets.contains(keyFor(b));
try list.append(arena, .{
.date = b.representative_date,
.is_live = false,
@ -663,7 +895,7 @@ fn collectCascadingTableRows(
});
if (expanded) {
try emitChildren(arena, app, series.points, b, &list, 1);
try emitChildren(arena, state, series.points, b, &list, 1);
}
}
@ -674,7 +906,7 @@ fn collectCascadingTableRows(
/// at top level and when a child is itself expanded.
fn emitChildren(
arena: std.mem.Allocator,
app: *const App,
state: *const State,
series: []const timeline.TimelinePoint,
parent: timeline.TierBucket,
list: *std.ArrayList(TableRow),
@ -706,7 +938,7 @@ fn emitChildren(
for (children, 0..) |c, idx| {
const d = if (idx == oldest_idx) oldest_delta else child_deltas[idx];
const expanded = app.history_expanded_buckets.contains(keyFor(c));
const expanded = state.expanded_buckets.contains(keyFor(c));
try list.append(arena, .{
.date = c.representative_date,
.is_live = false,
@ -727,7 +959,7 @@ fn emitChildren(
});
if (expanded and timeline.finerTier(c.tier) != null) {
try emitChildren(arena, app, series, c, list, indent + 1);
try emitChildren(arena, state, series, c, list, indent + 1);
}
}
}
@ -822,37 +1054,37 @@ fn buildLiveRow(app: *const App, deltas: []const timeline.RowDelta) ?TableRow {
// Rendering
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine {
// Compare mode short-circuits the timeline render.
if (app.history_compare_view) |cv| {
if (state.compare_view) |cv| {
// Compare view doesn't populate table metadata; reset so the
// cursor state remains sensible if the user exits back.
return renderCompareLines(arena, app.theme, cv);
}
const rows = try collectTableRows(arena, app);
const rows = try collectTableRows(arena, state, app);
const result = try renderHistoryLinesFull(
arena,
app.theme,
if (app.history_timeline) |tl| tl.series else null,
app.history_metric,
app.history_resolution,
if (state.tl) |tl| tl.series else null,
state.metric,
state.resolution,
rows,
app.history_cursor,
app.history_selections,
state.cursor,
state.selections,
);
// Stash table metadata on the App for the event handler's cursor-
// visibility logic.
app.history_table_first_line = result.table_first_line;
app.history_table_row_count = result.table_row_count;
// Stash table metadata on State for the event handler's cursor-
// visibility logic and the click hit-test.
state.table_first_line = result.table_first_line;
state.table_row_count = result.table_row_count;
// Clamp cursor in case the row count shrank since the last press
// (shouldn't happen often but guards against subtle off-by-ones).
if (result.table_row_count == 0) {
app.history_cursor = 0;
} else if (app.history_cursor >= result.table_row_count) {
app.history_cursor = result.table_row_count - 1;
state.cursor = 0;
} else if (state.cursor >= result.table_row_count) {
state.cursor = result.table_row_count - 1;
}
return result.lines;

View file

@ -170,7 +170,6 @@ pub const tab = struct {
pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool {
if (mouse.button != .left) return false;
if (mouse.type != .press) return false;
if (mouse.row == 0) return false; // tab bar App handles
const content_row = @as(usize, @intCast(mouse.row)) + app.scroll_offset;
if (content_row < state.header_lines) return false;
if (state.rows.items.len == 0) return false;

View file

@ -116,7 +116,6 @@ pub const tab = struct {
pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool {
if (mouse.button != .left) return false;
if (mouse.type != .press) return false;
if (mouse.row == 0) return false; // tab bar App handles
const tf_row = state.chart.timeframe_row orelse return false;
const content_row = @as(usize, @intCast(mouse.row)) + app.scroll_offset;
if (content_row != tf_row) return false;

View file

@ -43,6 +43,15 @@
//! // skips the call entirely. Each method returns `bool`
//! // true means "consumed; don't fall through to global
//! // handling."
//! //
//! // Chrome ownership: the framework owns the tab bar (row 0)
//! // and filters chrome-region events out before dispatching.
//! // Tab handlers receive only events the framework didn't
//! // claim so `handleMouse` will never see a row-0 click,
//! // and there's no need for tab handlers to test `row == 0`
//! // themselves. (Future: a per-tab "claim chrome region" hook
//! // would let a tab opt in to drawing into chrome and
//! // receiving its events; for now, chrome is framework-only.)
//! pub fn handleKey(state: *State, app: *App, key: vaxis.Key) bool { ... }
//! pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool { ... }
//! pub fn handlePaste(state: *State, app: *App, text: []const u8) bool { ... }