migrate history tab to new framework
This commit is contained in:
parent
e27904df69
commit
13145590d1
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 analysis_tab = @import("tui/analysis_tab.zig");
|
||||||
const history_tab = @import("tui/history_tab.zig");
|
const history_tab = @import("tui/history_tab.zig");
|
||||||
const projections_tab = @import("tui/projections_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.
|
/// Comptime-generated table of single-character grapheme slices with static lifetime.
|
||||||
/// This avoids dangling pointers from stack-allocated temporaries in draw functions.
|
/// This avoids dangling pointers from stack-allocated temporaries in draw functions.
|
||||||
const ascii_g = blk: {
|
const ascii_g = blk: {
|
||||||
|
|
@ -134,32 +129,6 @@ pub const StyledLine = struct {
|
||||||
cell_styles: ?[]const vaxis.Style = null,
|
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 ───────────────────────────────────────────
|
// ── Tab-specific types ───────────────────────────────────────────
|
||||||
// These logically belong to individual tab files, but live here because
|
// These logically belong to individual tab files, but live here because
|
||||||
// App's struct fields reference them and Zig requires field types to be
|
// 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 = .{},
|
quote: quote_tab.State = .{},
|
||||||
performance: performance_tab.State = .{},
|
performance: performance_tab.State = .{},
|
||||||
options: options_tab.State = .{},
|
options: options_tab.State = .{},
|
||||||
|
history: history_tab.State = .{},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Comptime registry of all tab modules conforming to the
|
/// Comptime registry of all tab modules conforming to the
|
||||||
|
|
@ -354,6 +324,7 @@ const tab_modules = .{
|
||||||
.quote = quote_tab,
|
.quote = quote_tab,
|
||||||
.performance = performance_tab,
|
.performance = performance_tab,
|
||||||
.options = options_tab,
|
.options = options_tab,
|
||||||
|
.history = history_tab,
|
||||||
};
|
};
|
||||||
|
|
||||||
comptime {
|
comptime {
|
||||||
|
|
@ -604,33 +575,7 @@ pub const App = struct {
|
||||||
account_search_matches: std.ArrayList(usize) = .empty, // indices into account_list matching search
|
account_search_matches: std.ArrayList(usize) = .empty, // indices into account_list matching search
|
||||||
account_search_cursor: usize = 0, // cursor within search_matches
|
account_search_cursor: usize = 0, // cursor within search_matches
|
||||||
|
|
||||||
// History tab state
|
// History tab state lives in `self.states.history` (see TabStates).
|
||||||
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,
|
|
||||||
|
|
||||||
// Projections tab state
|
// Projections tab state
|
||||||
projections_loaded: bool = false,
|
projections_loaded: bool = false,
|
||||||
|
|
@ -663,23 +608,6 @@ pub const App = struct {
|
||||||
/// meaningful when `projections_as_of` is set; the keybind
|
/// meaningful when `projections_as_of` is set; the keybind
|
||||||
/// flashes a status message and leaves this off otherwise.
|
/// flashes a status message and leaves this off otherwise.
|
||||||
projections_overlay_actuals: bool = false,
|
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).
|
// Mouse wheel debounce for cursor-based tabs (portfolio, options).
|
||||||
// Terminals often send multiple wheel events per physical tick.
|
// Terminals often send multiple wheel events per physical tick.
|
||||||
|
|
@ -786,7 +714,7 @@ pub const App = struct {
|
||||||
for (tabs) |t| {
|
for (tabs) |t| {
|
||||||
const lbl_len: i16 = @intCast(t.label().len);
|
const lbl_len: i16 = @intCast(t.label().len);
|
||||||
if (mouse.col >= col and mouse.col < col + lbl_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.active_tab = t;
|
||||||
self.scroll_offset = 0;
|
self.scroll_offset = 0;
|
||||||
self.loadTabData();
|
self.loadTabData();
|
||||||
|
|
@ -847,27 +775,24 @@ pub const App = struct {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Framework dispatch: ask the active tab's `handleMouse`
|
// Framework dispatch: ask the active tab's `handleMouse`
|
||||||
// (when defined) if it wants to consume this click. Tabs
|
// (when defined) if it wants to consume this click.
|
||||||
// without a `handleMouse` decl simply fall through.
|
//
|
||||||
if (self.dispatchBool("handleMouse", .{mouse})) {
|
// 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();
|
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 => {},
|
else => {},
|
||||||
}
|
}
|
||||||
|
|
@ -1363,7 +1288,7 @@ pub const App = struct {
|
||||||
// silently consume) these keys. This intercept runs first when
|
// silently consume) these keys. This intercept runs first when
|
||||||
// the user is in the history tab so compare behavior wins.
|
// the user is in the history tab so compare behavior wins.
|
||||||
if (self.active_tab == .history) {
|
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
|
// 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);
|
const idx = @intFromEnum(action) - @intFromEnum(keybinds.Action.tab_1);
|
||||||
if (idx < tabs.len) {
|
if (idx < tabs.len) {
|
||||||
const target = tabs[idx];
|
const target = tabs[idx];
|
||||||
if (self.isTabDisabled(target)) return;
|
if (self.isDisabled(target)) return;
|
||||||
self.active_tab = target;
|
self.active_tab = target;
|
||||||
self.scroll_offset = 0;
|
self.scroll_offset = 0;
|
||||||
self.loadTabData();
|
self.loadTabData();
|
||||||
|
|
@ -1458,9 +1383,8 @@ pub const App = struct {
|
||||||
options_tab.tab.handleAction(&self.states.options, self, options_tab.Action.expand_collapse);
|
options_tab.tab.handleAction(&self.states.options, self, options_tab.Action.expand_collapse);
|
||||||
return ctx.consumeAndRedraw();
|
return ctx.consumeAndRedraw();
|
||||||
} else if (self.active_tab == .history) {
|
} else if (self.active_tab == .history) {
|
||||||
if (history_tab.toggleTierAtCursor(self)) {
|
history_tab.tab.handleAction(&self.states.history, self, history_tab.Action.expand_collapse);
|
||||||
return ctx.consumeAndRedraw();
|
return ctx.consumeAndRedraw();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
.scroll_down => {
|
.scroll_down => {
|
||||||
|
|
@ -1543,13 +1467,13 @@ pub const App = struct {
|
||||||
},
|
},
|
||||||
.history_metric_next => {
|
.history_metric_next => {
|
||||||
if (self.active_tab == .history) {
|
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();
|
return ctx.consumeAndRedraw();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
.history_resolution_next => {
|
.history_resolution_next => {
|
||||||
if (self.active_tab == .history) {
|
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();
|
return ctx.consumeAndRedraw();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1667,24 +1591,28 @@ pub const App = struct {
|
||||||
// and so future user-supplied keybindings targeting these
|
// and so future user-supplied keybindings targeting these
|
||||||
// action names work correctly.
|
// action names work correctly.
|
||||||
.compare_select => {
|
.compare_select => {
|
||||||
if (self.active_tab == .history and self.history_compare_view == null and self.history_table_row_count > 0) {
|
if (self.active_tab == .history) {
|
||||||
history_tab.toggleSelectionAt(self, self.history_cursor);
|
const hs = &self.states.history;
|
||||||
return ctx.consumeAndRedraw();
|
if (hs.compare_view == null and hs.table_row_count > 0) {
|
||||||
|
history_tab.toggleSelectionAt(hs, self, hs.cursor);
|
||||||
|
return ctx.consumeAndRedraw();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
.compare_commit => {
|
.compare_commit => {
|
||||||
if (self.active_tab == .history) {
|
if (self.active_tab == .history) {
|
||||||
if (self.history_compare_view != null) {
|
const hs = &self.states.history;
|
||||||
history_tab.clearCompareState(self);
|
if (hs.compare_view != null) {
|
||||||
|
history_tab.clearCompareState(hs, self);
|
||||||
} else {
|
} else {
|
||||||
history_tab.commitCompareExternal(self);
|
history_tab.commitCompareExternal(hs, self);
|
||||||
}
|
}
|
||||||
return ctx.consumeAndRedraw();
|
return ctx.consumeAndRedraw();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
.compare_cancel => {
|
.compare_cancel => {
|
||||||
if (self.active_tab == .history) {
|
if (self.active_tab == .history) {
|
||||||
history_tab.clearCompareState(self);
|
history_tab.clearCompareState(&self.states.history, self);
|
||||||
return ctx.consumeAndRedraw();
|
return ctx.consumeAndRedraw();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1708,22 +1636,16 @@ pub const App = struct {
|
||||||
/// For other tabs (or cursor-bearing tabs with empty rows),
|
/// For other tabs (or cursor-bearing tabs with empty rows),
|
||||||
/// adjusts scroll_offset by |n|.
|
/// adjusts scroll_offset by |n|.
|
||||||
fn moveBy(self: *App, n: isize) void {
|
fn moveBy(self: *App, n: isize) void {
|
||||||
// Unmigrated cursor-bearing tabs (portfolio + history).
|
// Unmigrated cursor-bearing tab (portfolio).
|
||||||
// Their cursor state still lives on App; once migrated,
|
// Its cursor state still lives on App; once migrated, this
|
||||||
// both will move into onCursorMove hooks like options.
|
// branch goes into an onCursorMove hook like options/history.
|
||||||
if (self.active_tab == .portfolio) {
|
if (self.active_tab == .portfolio) {
|
||||||
if (self.shouldDebounceWheel()) return;
|
if (self.shouldDebounceWheel()) return;
|
||||||
stepCursor(&self.cursor, self.portfolio_rows.items.len, n);
|
stepCursor(&self.cursor, self.portfolio_rows.items.len, n);
|
||||||
self.ensureCursorVisible();
|
self.ensureCursorVisible();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (self.active_tab == .history and self.history_compare_view == null and self.history_table_row_count > 0) {
|
// Migrated cursor-bearing tabs (options, history). The
|
||||||
if (self.shouldDebounceWheel()) return;
|
|
||||||
stepCursor(&self.history_cursor, self.history_table_row_count, n);
|
|
||||||
self.ensureHistoryCursorVisible();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Migrated cursor-bearing tabs (currently: options). The
|
|
||||||
// hook returns false when it has no rows, so we fall
|
// hook returns false when it has no rows, so we fall
|
||||||
// through to scroll. Debounce applies to the cursor-move
|
// through to scroll. Debounce applies to the cursor-move
|
||||||
// path only — preserving legacy behavior where wheel
|
// 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 {
|
fn toggleExpand(self: *App) void {
|
||||||
if (self.portfolio_rows.items.len == 0) return;
|
if (self.portfolio_rows.items.len == 0) return;
|
||||||
if (self.cursor >= self.portfolio_rows.items.len) 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 {};
|
analysis_tab.tab.reload(&self.states.analysis, self) catch {};
|
||||||
},
|
},
|
||||||
.history => {
|
.history => {
|
||||||
self.history_loaded = false;
|
history_tab.tab.reload(&self.states.history, self) catch {};
|
||||||
history_tab.freeLoaded(self);
|
|
||||||
},
|
},
|
||||||
.projections => {
|
.projections => {
|
||||||
self.projections_loaded = false;
|
self.projections_loaded = false;
|
||||||
|
|
@ -1918,8 +1825,7 @@ pub const App = struct {
|
||||||
analysis_tab.tab.activate(&self.states.analysis, self) catch {};
|
analysis_tab.tab.activate(&self.states.analysis, self) catch {};
|
||||||
},
|
},
|
||||||
.history => {
|
.history => {
|
||||||
if (self.history_disabled) return;
|
history_tab.tab.activate(&self.states.history, self) catch {};
|
||||||
if (!self.history_loaded) history_tab.loadData(self);
|
|
||||||
},
|
},
|
||||||
.projections => {
|
.projections => {
|
||||||
if (self.projections_disabled) return;
|
if (self.projections_disabled) return;
|
||||||
|
|
@ -1977,7 +1883,7 @@ pub const App = struct {
|
||||||
if (self.filtered_positions) |fp| self.allocator.free(fp);
|
if (self.filtered_positions) |fp| self.allocator.free(fp);
|
||||||
analysis_tab.tab.deinit(&self.states.analysis, self);
|
analysis_tab.tab.deinit(&self.states.analysis, self);
|
||||||
self.portfolio.deinit(self.allocator);
|
self.portfolio.deinit(self.allocator);
|
||||||
history_tab.freeLoaded(self);
|
history_tab.tab.deinit(&self.states.history, self);
|
||||||
projections_tab.freeLoaded(self);
|
projections_tab.freeLoaded(self);
|
||||||
quote_tab.tab.deinit(&self.states.quote, self);
|
quote_tab.tab.deinit(&self.states.quote, self);
|
||||||
}
|
}
|
||||||
|
|
@ -2023,7 +1929,7 @@ pub const App = struct {
|
||||||
for (tabs) |t| {
|
for (tabs) |t| {
|
||||||
const lbl = t.label();
|
const lbl = t.label();
|
||||||
const is_active = t == self.active_tab;
|
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;
|
const tab_style: vaxis.Style = if (is_active) th.tabActiveStyle() else if (is_disabled) th.tabDisabledStyle() else inactive_style;
|
||||||
|
|
||||||
for (lbl) |ch| {
|
for (lbl) |ch| {
|
||||||
|
|
@ -2057,13 +1963,12 @@ pub const App = struct {
|
||||||
/// Whether the given tab should be treated as disabled in
|
/// Whether the given tab should be treated as disabled in
|
||||||
/// the current App context. Migrated tabs are consulted via
|
/// the current App context. Migrated tabs are consulted via
|
||||||
/// their framework-contract `isDisabled` hook; unmigrated tabs
|
/// their framework-contract `isDisabled` hook; unmigrated tabs
|
||||||
/// (history, projections) still use bespoke `_disabled` flags
|
/// (projections) still use bespoke `_disabled` flags set during
|
||||||
/// set during App init. The unmigrated branches go away when
|
/// App init. The unmigrated branch goes away when projections
|
||||||
/// those tabs adopt the framework — the `_disabled` fields
|
/// adopts the framework — the `_disabled` field will be deleted
|
||||||
/// will be deleted alongside the inline checks here.
|
/// alongside the inline check here.
|
||||||
fn isTabDisabled(self: *App, t: Tab) bool {
|
fn isDisabled(self: *App, t: Tab) bool {
|
||||||
return self.appPredicate(t, "isDisabled") or
|
return self.appPredicate(t, "isDisabled") or
|
||||||
(t == .history and self.history_disabled) or
|
|
||||||
(t == .projections and self.projections_disabled);
|
(t == .projections and self.projections_disabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2266,7 +2171,7 @@ pub const App = struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn buildHistoryStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
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 ─────────────────────────────────────────────────────
|
// ── Help ─────────────────────────────────────────────────────
|
||||||
|
|
@ -2345,7 +2250,7 @@ pub const App = struct {
|
||||||
var next_idx = if (idx + 1 < tabs.len) idx + 1 else 0;
|
var next_idx = if (idx + 1 < tabs.len) idx + 1 else 0;
|
||||||
// Skip disabled tabs (earnings for ETFs, analysis without portfolio)
|
// Skip disabled tabs (earnings for ETFs, analysis without portfolio)
|
||||||
var tries: usize = 0;
|
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;
|
next_idx = if (next_idx + 1 < tabs.len) next_idx + 1 else 0;
|
||||||
self.active_tab = tabs[next_idx];
|
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;
|
var prev_idx = if (idx > 0) idx - 1 else tabs.len - 1;
|
||||||
// Skip disabled tabs (earnings for ETFs, analysis without portfolio)
|
// Skip disabled tabs (earnings for ETFs, analysis without portfolio)
|
||||||
var tries: usize = 0;
|
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;
|
prev_idx = if (prev_idx > 0) prev_idx - 1 else tabs.len - 1;
|
||||||
self.active_tab = tabs[prev_idx];
|
self.active_tab = tabs[prev_idx];
|
||||||
}
|
}
|
||||||
|
|
@ -2598,9 +2503,11 @@ pub fn run(
|
||||||
.symbol = symbol,
|
.symbol = symbol,
|
||||||
.has_explicit_symbol = has_explicit_symbol,
|
.has_explicit_symbol = has_explicit_symbol,
|
||||||
.chart_config = chart_config,
|
.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| {
|
if (portfolio_path) |path| {
|
||||||
const file_data = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(10 * 1024 * 1024)) catch null;
|
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.
|
// Disable projections tab when no portfolio is loaded.
|
||||||
// Analysis derives the same condition via its `isDisabled`
|
// Analysis derives the same condition via its `isDisabled`
|
||||||
// method (no field write needed) — the predicate is computed
|
// 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) {
|
if (app_inst.portfolio.file == null) {
|
||||||
app_inst.projections_disabled = true;
|
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.
|
// Pre-fetch portfolio prices before TUI starts, with stderr progress.
|
||||||
// This runs while the terminal is still in normal mode so output is visible.
|
// 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 view = @import("../views/history.zig");
|
||||||
const compare_core = @import("../compare.zig");
|
const compare_core = @import("../compare.zig");
|
||||||
const compare_view = @import("../views/compare.zig");
|
const compare_view = @import("../views/compare.zig");
|
||||||
|
const framework = @import("tab_framework.zig");
|
||||||
const App = tui.App;
|
const App = tui.App;
|
||||||
const StyledLine = tui.StyledLine;
|
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
|
/// `bucket_start.days` alone collides on edge-aligned parents
|
||||||
/// and children — e.g. yearly 2024 starts on 2024-01-01, and so
|
/// and children — e.g. yearly 2024 starts on 2024-01-01, and so
|
||||||
/// does its child quarterly Q1 2024. Tagging by tier
|
/// does its child quarterly Q1 2024. Tagging by tier
|
||||||
|
|
@ -71,50 +295,50 @@ fn keyForRow(row: TableRow) ?BucketKey {
|
||||||
|
|
||||||
// ── Data loading ──────────────────────────────────────────────
|
// ── Data loading ──────────────────────────────────────────────
|
||||||
|
|
||||||
pub fn loadData(app: *App) void {
|
pub fn loadData(state: *State, app: *App) void {
|
||||||
app.history_loaded = true;
|
state.loaded = true;
|
||||||
freeLoaded(app);
|
freeLoaded(state, app);
|
||||||
|
|
||||||
const portfolio_path = app.portfolio_path orelse {
|
const portfolio_path = app.portfolio_path orelse {
|
||||||
app.setStatus("History tab requires a loaded portfolio");
|
app.setStatus("History tab requires a loaded portfolio");
|
||||||
return;
|
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");
|
app.setStatus("Failed to read history/ directory");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (app.history_timeline.?.loaded.snapshots.len == 0) {
|
if (state.tl.?.loaded.snapshots.len == 0) {
|
||||||
freeLoaded(app);
|
freeLoaded(state, app);
|
||||||
app.setStatus("No snapshots in history/ (run: zfin snapshot)");
|
app.setStatus("No snapshots in history/ (run: zfin snapshot)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Release the loaded timeline (if any).
|
/// Release the loaded timeline (if any).
|
||||||
pub fn freeLoaded(app: *App) void {
|
pub fn freeLoaded(state: *State, app: *App) void {
|
||||||
if (app.history_timeline) |*tl| {
|
if (state.tl) |*tl| {
|
||||||
tl.deinit();
|
tl.deinit();
|
||||||
app.history_timeline = null;
|
state.tl = null;
|
||||||
}
|
}
|
||||||
clearCompareView(app);
|
clearCompareView(state, app);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear the compare-view state (selections are preserved).
|
/// Clear the compare-view state (selections are preserved).
|
||||||
fn clearCompareView(app: *App) void {
|
fn clearCompareView(state: *State, app: *App) void {
|
||||||
if (app.history_compare_view) |*cv| {
|
if (state.compare_view) |*cv| {
|
||||||
cv.deinit(app.allocator);
|
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);
|
res.deinit(app.allocator);
|
||||||
app.history_compare_resources = null;
|
state.compare_resources = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cycle the displayed metric: liquid → illiquid → net_worth → liquid.
|
/// Cycle the displayed metric: liquid → illiquid → net_worth → liquid.
|
||||||
pub fn cycleMetric(app: *App) void {
|
pub fn cycleMetric(state: *State) void {
|
||||||
app.history_metric = switch (app.history_metric) {
|
state.metric = switch (state.metric) {
|
||||||
.liquid => .illiquid,
|
.liquid => .illiquid,
|
||||||
.illiquid => .net_worth,
|
.illiquid => .net_worth,
|
||||||
.net_worth => .liquid,
|
.net_worth => .liquid,
|
||||||
|
|
@ -122,10 +346,10 @@ pub fn cycleMetric(app: *App) void {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cycle resolution: cascading → daily → weekly → monthly → cascading.
|
/// Cycle resolution: cascading → daily → weekly → monthly → cascading.
|
||||||
pub fn cycleResolution(app: *App) void {
|
pub fn cycleResolution(state: *State) void {
|
||||||
app.history_resolution = switch (app.history_resolution orelse {
|
state.resolution = switch (state.resolution orelse {
|
||||||
// null means "default" — i.e. cascading. Step to daily.
|
// null means "default" — i.e. cascading. Step to daily.
|
||||||
app.history_resolution = .daily;
|
state.resolution = .daily;
|
||||||
return;
|
return;
|
||||||
}) {
|
}) {
|
||||||
.cascading => .daily,
|
.cascading => .daily,
|
||||||
|
|
@ -139,24 +363,24 @@ pub fn cycleResolution(app: *App) void {
|
||||||
/// bucket row, toggle its expanded state. Returns true if a
|
/// bucket row, toggle its expanded state. Returns true if a
|
||||||
/// toggle happened (so the caller knows to consume the keypress
|
/// toggle happened (so the caller knows to consume the keypress
|
||||||
/// and trigger a redraw).
|
/// 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);
|
var arena_state = std.heap.ArenaAllocator.init(app.allocator);
|
||||||
defer arena_state.deinit();
|
defer arena_state.deinit();
|
||||||
const arena = arena_state.allocator();
|
const arena = arena_state.allocator();
|
||||||
|
|
||||||
const rows = collectTableRows(arena, app) catch return false;
|
const rows = collectTableRows(arena, state, app) catch return false;
|
||||||
if (app.history_cursor >= rows.len) return false;
|
if (state.cursor >= rows.len) return false;
|
||||||
const row = rows[app.history_cursor];
|
const row = rows[state.cursor];
|
||||||
|
|
||||||
// Only buckets with a finer tier are expandable. Live rows,
|
// Only buckets with a finer tier are expandable. Live rows,
|
||||||
// daily rows, and rows without bucket_start are not.
|
// daily rows, and rows without bucket_start are not.
|
||||||
if (!row.has_children) return false;
|
if (!row.has_children) return false;
|
||||||
const key = keyForRow(row) orelse return false;
|
const key = keyForRow(row) orelse return false;
|
||||||
|
|
||||||
if (app.history_expanded_buckets.contains(key)) {
|
if (state.expanded_buckets.contains(key)) {
|
||||||
_ = app.history_expanded_buckets.remove(key);
|
_ = state.expanded_buckets.remove(key);
|
||||||
} else {
|
} else {
|
||||||
app.history_expanded_buckets.put(key, {}) catch return false;
|
state.expanded_buckets.put(key, {}) catch return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -164,17 +388,17 @@ pub fn toggleTierAtCursor(app: *App) bool {
|
||||||
// ── Compare selection model ──────────────────────────────────
|
// ── Compare selection model ──────────────────────────────────
|
||||||
|
|
||||||
/// Returns the number of currently selected rows (0, 1, or 2).
|
/// 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;
|
var n: usize = 0;
|
||||||
for (app.history_selections) |s| {
|
for (state.selections) |s| {
|
||||||
if (s != null) n += 1;
|
if (s != null) n += 1;
|
||||||
}
|
}
|
||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if row `idx` is currently selected.
|
/// Returns true if row `idx` is currently selected.
|
||||||
fn isSelected(app: *const App, idx: usize) bool {
|
fn isSelected(state: *const State, idx: usize) bool {
|
||||||
for (app.history_selections) |s| {
|
for (state.selections) |s| {
|
||||||
if (s) |v| if (v == idx) return true;
|
if (s) |v| if (v == idx) return true;
|
||||||
}
|
}
|
||||||
return false;
|
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
|
/// 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.
|
/// 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.
|
// Already selected? Remove.
|
||||||
for (&app.history_selections) |*slot| {
|
for (&state.selections) |*slot| {
|
||||||
if (slot.*) |v| {
|
if (slot.*) |v| {
|
||||||
if (v == idx) {
|
if (v == idx) {
|
||||||
slot.* = null;
|
slot.* = null;
|
||||||
setSelectionStatus(app);
|
setSelectionStatus(state, app);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Find an empty slot.
|
// Find an empty slot.
|
||||||
for (&app.history_selections) |*slot| {
|
for (&state.selections) |*slot| {
|
||||||
if (slot.* == null) {
|
if (slot.* == null) {
|
||||||
slot.* = idx;
|
slot.* = idx;
|
||||||
setSelectionStatus(app);
|
setSelectionStatus(state, app);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -206,12 +430,12 @@ fn toggleSelection(app: *App, idx: usize) void {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear all selections.
|
/// Clear all selections.
|
||||||
fn clearSelections(app: *App) void {
|
fn clearSelections(state: *State) void {
|
||||||
app.history_selections = .{ null, null };
|
state.selections = .{ null, null };
|
||||||
}
|
}
|
||||||
|
|
||||||
fn setSelectionStatus(app: *App) void {
|
fn setSelectionStatus(state: *const State, app: *App) void {
|
||||||
const n = selectionCount(app);
|
const n = selectionCount(state);
|
||||||
switch (n) {
|
switch (n) {
|
||||||
0 => app.setStatus(""),
|
0 => app.setStatus(""),
|
||||||
1 => app.setStatus("Selected 1 row — select one more + press 'c' to compare"),
|
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
|
/// Public entry for the `compare_select` action dispatched via
|
||||||
/// matchAction. Internally delegates to the same toggle function the
|
/// matchAction. Internally delegates to the same toggle function the
|
||||||
/// intercept uses.
|
/// intercept uses.
|
||||||
pub fn toggleSelectionAt(app: *App, idx: usize) void {
|
pub fn toggleSelectionAt(state: *State, app: *App, idx: usize) void {
|
||||||
toggleSelection(app, idx);
|
toggleSelection(state, app, idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Public entry for the `compare_commit` action.
|
/// Public entry for the `compare_commit` action.
|
||||||
pub fn commitCompareExternal(app: *App) void {
|
pub fn commitCompareExternal(state: *State, app: *App) void {
|
||||||
commitCompare(app);
|
commitCompare(state, app);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Public entry for the `compare_cancel` action or internal Esc path:
|
/// Public entry for the `compare_cancel` action or internal Esc path:
|
||||||
/// clear selections and any active compare view.
|
/// clear selections and any active compare view.
|
||||||
pub fn clearCompareState(app: *App) void {
|
pub fn clearCompareState(state: *State, app: *App) void {
|
||||||
clearCompareView(app);
|
clearCompareView(state, app);
|
||||||
clearSelections(app);
|
clearSelections(state);
|
||||||
app.setStatus("");
|
app.setStatus("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -244,8 +468,8 @@ pub fn clearCompareState(app: *App) void {
|
||||||
|
|
||||||
/// Attempt to run compare. Called when the user presses `c`.
|
/// Attempt to run compare. Called when the user presses `c`.
|
||||||
/// No-ops (with status hint) if the selection set isn't exactly 2.
|
/// No-ops (with status hint) if the selection set isn't exactly 2.
|
||||||
fn commitCompare(app: *App) void {
|
fn commitCompare(state: *State, app: *App) void {
|
||||||
const sel_count = selectionCount(app);
|
const sel_count = selectionCount(state);
|
||||||
if (sel_count < 2) {
|
if (sel_count < 2) {
|
||||||
if (sel_count == 0) {
|
if (sel_count == 0) {
|
||||||
app.setStatus("Select two rows with 's' (or space), then press 'c' to compare");
|
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.
|
// At this point both slots are filled.
|
||||||
const sel_a = app.history_selections[0].?;
|
const sel_a = state.selections[0].?;
|
||||||
const sel_b = app.history_selections[1].?;
|
const sel_b = state.selections[1].?;
|
||||||
if (sel_a == sel_b) {
|
if (sel_a == sel_b) {
|
||||||
// Shouldn't happen via toggle logic, but guard anyway.
|
// Shouldn't happen via toggle logic, but guard anyway.
|
||||||
app.setStatus("Selected rows are the same — clear one and reselect");
|
app.setStatus("Selected rows are the same — clear one and reselect");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
buildCompareFromSelections(app, sel_a, sel_b) catch |err| {
|
buildCompareFromSelections(state, app, sel_a, sel_b) catch |err| {
|
||||||
var msg_buf: [128]u8 = undefined;
|
var msg_buf: [128]u8 = undefined;
|
||||||
// Translate the most common failure modes into actionable
|
// Translate the most common failure modes into actionable
|
||||||
// messages. We already short-circuit imported-only rows
|
// 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",
|
else => std.fmt.bufPrint(&msg_buf, "Compare failed: {s}", .{@errorName(err)}) catch "Compare failed",
|
||||||
};
|
};
|
||||||
app.setStatus(msg);
|
app.setStatus(msg);
|
||||||
clearCompareView(app);
|
clearCompareView(state, app);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build + stash the compare view from two selected row indices.
|
/// 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
|
// Resolve each row-index into its date and source (live vs
|
||||||
// snapshot). This requires re-computing the table row list the
|
// snapshot). This requires re-computing the table row list the
|
||||||
// way the renderer does — shared helper keeps the two paths in
|
// 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();
|
defer arena_state.deinit();
|
||||||
const arena = arena_state.allocator();
|
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) {
|
if (rows.len == 0 or sel_a >= rows.len or sel_b >= rows.len) {
|
||||||
app.setStatus("Stale selection — please re-select");
|
app.setStatus("Stale selection — please re-select");
|
||||||
clearSelections(app);
|
clearSelections(state);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const row_a = rows[sel_a];
|
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.
|
// status before we try (and fail) to open the snapshot file.
|
||||||
if (older.imported_only or newer.imported_only) {
|
if (older.imported_only or newer.imported_only) {
|
||||||
app.setStatus("Cannot compare: imported-only history has no per-symbol detail");
|
app.setStatus("Cannot compare: imported-only history has no per-symbol detail");
|
||||||
clearSelections(app);
|
clearSelections(state);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build up the resources + maps for each side.
|
// Build up the resources + maps for each side.
|
||||||
var resources: tui.HistoryCompareResources = .{};
|
var resources: CompareResources = .{};
|
||||||
errdefer resources.deinit(app.allocator);
|
errdefer resources.deinit(app.allocator);
|
||||||
|
|
||||||
const portfolio_path = app.portfolio_path orelse {
|
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,
|
newer.is_live,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Commit: install both onto the App. From this point onwards the
|
// Commit: install both onto State. From this point onwards State
|
||||||
// App owns them and clearCompareView handles teardown.
|
// owns them and clearCompareView handles teardown.
|
||||||
clearCompareView(app);
|
clearCompareView(state, app);
|
||||||
app.history_compare_view = cv_with_labels;
|
state.compare_view = cv_with_labels;
|
||||||
app.history_compare_resources = resources;
|
state.compare_resources = resources;
|
||||||
app.setStatus("Comparing — Esc or 'c' to return to timeline");
|
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
|
/// Intercepted before `matchAction` runs when the active tab is
|
||||||
/// history. Returns true if the key was consumed.
|
/// 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.
|
// Escape: exit compare view, or clear selections.
|
||||||
if (key.codepoint == vaxis.Key.escape) {
|
if (key.codepoint == vaxis.Key.escape) {
|
||||||
if (app.history_compare_view != null) {
|
if (state.compare_view != null) {
|
||||||
clearCompareView(app);
|
clearCompareView(state, app);
|
||||||
clearSelections(app);
|
clearSelections(state);
|
||||||
app.setStatus("");
|
app.setStatus("");
|
||||||
ctx.consumeAndRedraw();
|
ctx.consumeAndRedraw();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (selectionCount(app) > 0) {
|
if (selectionCount(state) > 0) {
|
||||||
clearSelections(app);
|
clearSelections(state);
|
||||||
app.setStatus("");
|
app.setStatus("");
|
||||||
ctx.consumeAndRedraw();
|
ctx.consumeAndRedraw();
|
||||||
return true;
|
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.
|
// 's' or space: toggle selection on the cursor row.
|
||||||
if (key.matches('s', .{}) or key.matches(vaxis.Key.space, .{})) {
|
if (key.matches('s', .{}) or key.matches(vaxis.Key.space, .{})) {
|
||||||
// Disabled while the compare view is up — Esc to return first.
|
// Disabled while the compare view is up — Esc to return first.
|
||||||
if (app.history_compare_view != null) return false;
|
if (state.compare_view != null) return false;
|
||||||
if (app.history_table_row_count == 0) return false;
|
if (state.table_row_count == 0) return false;
|
||||||
toggleSelection(app, app.history_cursor);
|
toggleSelection(state, app, state.cursor);
|
||||||
ctx.consumeAndRedraw();
|
ctx.consumeAndRedraw();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 'c': commit compare, or exit compare view if already active.
|
// 'c': commit compare, or exit compare view if already active.
|
||||||
if (key.matches('c', .{})) {
|
if (key.matches('c', .{})) {
|
||||||
if (app.history_compare_view != null) {
|
if (state.compare_view != null) {
|
||||||
clearCompareView(app);
|
clearCompareView(state, app);
|
||||||
clearSelections(app);
|
clearSelections(state);
|
||||||
app.setStatus("");
|
app.setStatus("");
|
||||||
ctx.consumeAndRedraw();
|
ctx.consumeAndRedraw();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
commitCompare(app);
|
commitCompare(state, app);
|
||||||
ctx.consumeAndRedraw();
|
ctx.consumeAndRedraw();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -554,8 +785,8 @@ pub const TierHeader = struct {
|
||||||
/// rows from newest to oldest. The `RowDelta` slice from
|
/// rows from newest to oldest. The `RowDelta` slice from
|
||||||
/// `computeRowDeltas` is oldest-first; we reverse it and optionally
|
/// `computeRowDeltas` is oldest-first; we reverse it and optionally
|
||||||
/// prepend the live row.
|
/// prepend the live row.
|
||||||
pub fn collectTableRows(arena: std.mem.Allocator, app: *const App) ![]TableRow {
|
pub fn collectTableRows(arena: std.mem.Allocator, state: *const State, app: *const App) ![]TableRow {
|
||||||
const timeline_opt = app.history_timeline;
|
const timeline_opt = state.tl;
|
||||||
if (timeline_opt == null) return &.{};
|
if (timeline_opt == null) return &.{};
|
||||||
const series = timeline_opt.?.series;
|
const series = timeline_opt.?.series;
|
||||||
if (series.points.len == 0) return &.{};
|
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
|
// - explicit non-cascading: use the legacy flat-aggregation
|
||||||
// path (preserves existing behavior for daily/weekly/monthly).
|
// path (preserves existing behavior for daily/weekly/monthly).
|
||||||
// - explicit cascading OR null (default): build the tiered view.
|
// - 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;
|
const use_cascading = explicit == null or explicit.? == .cascading;
|
||||||
|
|
||||||
if (!use_cascading) {
|
if (!use_cascading) {
|
||||||
return collectFlatTableRows(arena, app, series, explicit.?);
|
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
|
/// Legacy path: flat aggregation by single resolution. Preserved
|
||||||
|
|
@ -623,6 +854,7 @@ fn collectFlatTableRows(
|
||||||
/// bucket — they're already at leaf granularity).
|
/// bucket — they're already at leaf granularity).
|
||||||
fn collectCascadingTableRows(
|
fn collectCascadingTableRows(
|
||||||
arena: std.mem.Allocator,
|
arena: std.mem.Allocator,
|
||||||
|
state: *const State,
|
||||||
app: *const App,
|
app: *const App,
|
||||||
series: timeline.TimelineSeries,
|
series: timeline.TimelineSeries,
|
||||||
) ![]TableRow {
|
) ![]TableRow {
|
||||||
|
|
@ -642,7 +874,7 @@ fn collectCascadingTableRows(
|
||||||
|
|
||||||
for (ts.buckets, 0..) |b, idx| {
|
for (ts.buckets, 0..) |b, idx| {
|
||||||
const d = top_deltas[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, .{
|
try list.append(arena, .{
|
||||||
.date = b.representative_date,
|
.date = b.representative_date,
|
||||||
.is_live = false,
|
.is_live = false,
|
||||||
|
|
@ -663,7 +895,7 @@ fn collectCascadingTableRows(
|
||||||
});
|
});
|
||||||
|
|
||||||
if (expanded) {
|
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.
|
/// at top level and when a child is itself expanded.
|
||||||
fn emitChildren(
|
fn emitChildren(
|
||||||
arena: std.mem.Allocator,
|
arena: std.mem.Allocator,
|
||||||
app: *const App,
|
state: *const State,
|
||||||
series: []const timeline.TimelinePoint,
|
series: []const timeline.TimelinePoint,
|
||||||
parent: timeline.TierBucket,
|
parent: timeline.TierBucket,
|
||||||
list: *std.ArrayList(TableRow),
|
list: *std.ArrayList(TableRow),
|
||||||
|
|
@ -706,7 +938,7 @@ fn emitChildren(
|
||||||
|
|
||||||
for (children, 0..) |c, idx| {
|
for (children, 0..) |c, idx| {
|
||||||
const d = if (idx == oldest_idx) oldest_delta else child_deltas[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, .{
|
try list.append(arena, .{
|
||||||
.date = c.representative_date,
|
.date = c.representative_date,
|
||||||
.is_live = false,
|
.is_live = false,
|
||||||
|
|
@ -727,7 +959,7 @@ fn emitChildren(
|
||||||
});
|
});
|
||||||
|
|
||||||
if (expanded and timeline.finerTier(c.tier) != null) {
|
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 ─────────────────────────────────────────────────
|
// ── 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.
|
// 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
|
// Compare view doesn't populate table metadata; reset so the
|
||||||
// cursor state remains sensible if the user exits back.
|
// cursor state remains sensible if the user exits back.
|
||||||
return renderCompareLines(arena, app.theme, cv);
|
return renderCompareLines(arena, app.theme, cv);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = try collectTableRows(arena, app);
|
const rows = try collectTableRows(arena, state, app);
|
||||||
const result = try renderHistoryLinesFull(
|
const result = try renderHistoryLinesFull(
|
||||||
arena,
|
arena,
|
||||||
app.theme,
|
app.theme,
|
||||||
if (app.history_timeline) |tl| tl.series else null,
|
if (state.tl) |tl| tl.series else null,
|
||||||
app.history_metric,
|
state.metric,
|
||||||
app.history_resolution,
|
state.resolution,
|
||||||
rows,
|
rows,
|
||||||
app.history_cursor,
|
state.cursor,
|
||||||
app.history_selections,
|
state.selections,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Stash table metadata on the App for the event handler's cursor-
|
// Stash table metadata on State for the event handler's cursor-
|
||||||
// visibility logic.
|
// visibility logic and the click hit-test.
|
||||||
app.history_table_first_line = result.table_first_line;
|
state.table_first_line = result.table_first_line;
|
||||||
app.history_table_row_count = result.table_row_count;
|
state.table_row_count = result.table_row_count;
|
||||||
|
|
||||||
// Clamp cursor in case the row count shrank since the last press
|
// Clamp cursor in case the row count shrank since the last press
|
||||||
// (shouldn't happen often but guards against subtle off-by-ones).
|
// (shouldn't happen often but guards against subtle off-by-ones).
|
||||||
if (result.table_row_count == 0) {
|
if (result.table_row_count == 0) {
|
||||||
app.history_cursor = 0;
|
state.cursor = 0;
|
||||||
} else if (app.history_cursor >= result.table_row_count) {
|
} else if (state.cursor >= result.table_row_count) {
|
||||||
app.history_cursor = result.table_row_count - 1;
|
state.cursor = result.table_row_count - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.lines;
|
return result.lines;
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,6 @@ pub const tab = struct {
|
||||||
pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool {
|
pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool {
|
||||||
if (mouse.button != .left) return false;
|
if (mouse.button != .left) return false;
|
||||||
if (mouse.type != .press) 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;
|
const content_row = @as(usize, @intCast(mouse.row)) + app.scroll_offset;
|
||||||
if (content_row < state.header_lines) return false;
|
if (content_row < state.header_lines) return false;
|
||||||
if (state.rows.items.len == 0) 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 {
|
pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool {
|
||||||
if (mouse.button != .left) return false;
|
if (mouse.button != .left) return false;
|
||||||
if (mouse.type != .press) 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 tf_row = state.chart.timeframe_row orelse return false;
|
||||||
const content_row = @as(usize, @intCast(mouse.row)) + app.scroll_offset;
|
const content_row = @as(usize, @intCast(mouse.row)) + app.scroll_offset;
|
||||||
if (content_row != tf_row) return false;
|
if (content_row != tf_row) return false;
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,15 @@
|
||||||
//! // skips the call entirely. Each method returns `bool` —
|
//! // skips the call entirely. Each method returns `bool` —
|
||||||
//! // true means "consumed; don't fall through to global
|
//! // true means "consumed; don't fall through to global
|
||||||
//! // handling."
|
//! // 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 handleKey(state: *State, app: *App, key: vaxis.Key) bool { ... }
|
||||||
//! pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool { ... }
|
//! pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool { ... }
|
||||||
//! pub fn handlePaste(state: *State, app: *App, text: []const u8) bool { ... }
|
//! pub fn handlePaste(state: *State, app: *App, text: []const u8) bool { ... }
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue