From 61dd86dc9a7512230b1cc1e66c06f19bb833684d Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Thu, 14 May 2026 18:29:37 -0700 Subject: [PATCH] migrate history tab to new framework --- src/tui.zig | 215 ++++++------------- src/tui/history_tab.zig | 434 +++++++++++++++++++++++++++++--------- src/tui/options_tab.zig | 1 - src/tui/quote_tab.zig | 1 - src/tui/tab_framework.zig | 9 + 5 files changed, 401 insertions(+), 259 deletions(-) diff --git a/src/tui.zig b/src/tui.zig index d6f9419..706be8a 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -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. diff --git a/src/tui/history_tab.zig b/src/tui/history_tab.zig index f598a7e..04dee95 100644 --- a/src/tui/history_tab.zig +++ b/src/tui/history_tab.zig @@ -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; diff --git a/src/tui/options_tab.zig b/src/tui/options_tab.zig index 0a6b69f..e2b61cf 100644 --- a/src/tui/options_tab.zig +++ b/src/tui/options_tab.zig @@ -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; diff --git a/src/tui/quote_tab.zig b/src/tui/quote_tab.zig index 3cca4b3..74e3931 100644 --- a/src/tui/quote_tab.zig +++ b/src/tui/quote_tab.zig @@ -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; diff --git a/src/tui/tab_framework.zig b/src/tui/tab_framework.zig index 308ce55..918c72a 100644 --- a/src/tui/tab_framework.zig +++ b/src/tui/tab_framework.zig @@ -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 { ... }