migrate history tab to new framework
This commit is contained in:
parent
d4d8961eff
commit
61dd86dc9a
5 changed files with 401 additions and 259 deletions
215
src/tui.zig
215
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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 { ... }
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue