migrate history tab to new keybindings

This commit is contained in:
Emil Lerch 2026-05-15 10:50:22 -07:00
parent 23a11ddded
commit 0b2dad0cf1
Signed by: lobo
GPG key ID: A7B62D657EF764F8
3 changed files with 71 additions and 187 deletions

View file

@ -1243,19 +1243,12 @@ pub const App = struct {
return ctx.consumeAndRedraw();
}
// History-tab compare intercept.
//
// `s` / space / `c` / escape have existing global bindings
// (select_symbol, collapse_all_calls, plus the account-filter
// escape handler below) that would otherwise handle (or
// 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 (tab_modules.history.handleCompareKey(&self.states.history, self, ctx, key)) return;
}
// Escape: clear account filter on portfolio tab, clear as-of
// on projections tab, no-op otherwise.
// Escape: portfolio tab clears its account filter; projections
// tab clears its as-of date. Other tabs fall through to the
// global keymap (no global Esc binding) and then to the
// tab-local fallback dispatcher (e.g. history binds Esc to
// `compare_cancel`). When portfolio/projections migrate,
// their Esc handling moves into tab-local actions too.
if (key.codepoint == vaxis.Key.escape) {
if (self.active_tab == .portfolio and self.states.portfolio.account_filter != null) {
self.setAccountFilter(null);
@ -1273,7 +1266,9 @@ pub const App = struct {
self.setStatus("As-of cleared — showing live");
return ctx.consumeAndRedraw();
}
return;
// Fall through no tab-specific handler. Esc isn't
// globally bound, so matchAction returns null and the
// tab-local fallback gets a chance.
}
const action = self.keymap.matchAction(key) orelse {
@ -1294,13 +1289,6 @@ pub const App = struct {
self.input_len = 0;
return ctx.consumeAndRedraw();
},
.select_symbol => {
// 's' selects the current portfolio row's symbol as the active symbol
if (self.active_tab == .portfolio) {
tab_modules.portfolio.tab.handleAction(&self.states.portfolio, self, tab_modules.portfolio.Action.select_symbol);
return ctx.consumeAndRedraw();
}
},
.refresh => {
self.refreshCurrentTab();
return ctx.consumeAndRedraw();
@ -1391,12 +1379,6 @@ pub const App = struct {
self.reloadPortfolioFile();
return ctx.consumeAndRedraw();
},
.collapse_all_calls => {
if (self.active_tab == .options) {
tab_modules.options.tab.handleAction(&self.states.options, self, tab_modules.options.Action.collapse_all_calls);
return ctx.consumeAndRedraw();
}
},
.collapse_all_puts => {
if (self.active_tab == .options) {
tab_modules.options.tab.handleAction(&self.states.options, self, tab_modules.options.Action.collapse_all_puts);
@ -1414,18 +1396,6 @@ pub const App = struct {
return ctx.consumeAndRedraw();
}
},
.history_metric_next => {
if (self.active_tab == .history) {
tab_modules.history.tab.handleAction(&self.states.history, self, tab_modules.history.Action.metric_next);
return ctx.consumeAndRedraw();
}
},
.history_resolution_next => {
if (self.active_tab == .history) {
tab_modules.history.tab.handleAction(&self.states.history, self, tab_modules.history.Action.resolution_next);
return ctx.consumeAndRedraw();
}
},
.sort_col_next => {
if (self.active_tab == .portfolio) {
tab_modules.portfolio.tab.handleAction(&self.states.portfolio, self, tab_modules.portfolio.Action.sort_col_next);
@ -1484,38 +1454,6 @@ pub const App = struct {
return ctx.consumeAndRedraw();
}
},
// History-tab compare actions are normally intercepted in
// `handleCompareKey` before `matchAction` runs (because the
// default 's'/'c'/space/escape key bindings belong to other
// actions). These cases exist so the switch is exhaustive
// and so future user-supplied keybindings targeting these
// action names work correctly.
.compare_select => {
if (self.active_tab == .history) {
const hs = &self.states.history;
if (hs.compare_view == null and hs.table_row_count > 0) {
tab_modules.history.toggleSelectionAt(hs, self, hs.cursor);
return ctx.consumeAndRedraw();
}
}
},
.compare_commit => {
if (self.active_tab == .history) {
const hs = &self.states.history;
if (hs.compare_view != null) {
tab_modules.history.clearCompareState(hs, self);
} else {
tab_modules.history.commitCompareExternal(hs, self);
}
return ctx.consumeAndRedraw();
}
},
.compare_cancel => {
if (self.active_tab == .history) {
tab_modules.history.clearCompareState(&self.states.history, self);
return ctx.consumeAndRedraw();
}
},
}
}

View file

@ -28,11 +28,7 @@
//! `src/history.zig` (snapshot IO). Compare composition is delegated
//! to `src/compare.zig`; compare rendering to `src/views/compare.zig`.
//!
//! Keybinds:
//! - `m` cycles chart metric (`history_metric_next`)
//! - `t` cycles resolution (`history_resolution_next`)
//! - `s` / space / `c` / Esc compare (intercepted in `tui.zig`
//! before matchAction, see `handleCompareKey` below)
//! Keybinds: see the `Action` enum + `default_bindings` below.
const std = @import("std");
const vaxis = @import("vaxis");
@ -60,13 +56,20 @@ const StyledLine = tui.StyledLine;
// (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.
// 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 : toggle inclusion of the cursor row in
// the compare-selection set (0, 1, or 2).
// - `c` : commit compare (requires 2 rows
// selected) or exit an active compare view.
// - Esc : cancel compare view or clear pending
// selections.
pub const Action = enum {
/// Toggle expansion of the bucket at the cursor. No-op for
@ -76,6 +79,15 @@ pub const Action = enum {
metric_next,
/// Cycle the resolution: cascading daily weekly monthly cascading.
resolution_next,
/// Toggle inclusion of the cursor row in the compare selection set.
/// Disabled while a compare view is up. Bound to `s` and space.
compare_select,
/// Run compare if exactly 2 rows are selected (otherwise status
/// hint), or exit an active compare view. Bound to `c`.
compare_commit,
/// Cancel compare: drop active compare view if up, else clear
/// pending selections. No-op when nothing is active. Bound to Esc.
compare_cancel,
};
// Tab-private state
@ -156,12 +168,19 @@ pub const tab = struct {
.{ .action = .expand_collapse, .key = .{ .codepoint = vaxis.Key.enter } },
.{ .action = .metric_next, .key = .{ .codepoint = 'm' } },
.{ .action = .resolution_next, .key = .{ .codepoint = 't' } },
.{ .action = .compare_select, .key = .{ .codepoint = 's' } },
.{ .action = .compare_select, .key = .{ .codepoint = vaxis.Key.space } },
.{ .action = .compare_commit, .key = .{ .codepoint = 'c' } },
.{ .action = .compare_cancel, .key = .{ .codepoint = vaxis.Key.escape } },
};
pub const action_labels = std.enums.EnumArray(Action, []const u8).init(.{
.expand_collapse = "Expand/collapse bucket",
.metric_next = "Cycle metric",
.resolution_next = "Cycle resolution",
.compare_select = "Toggle compare selection",
.compare_commit = "Compare selected rows",
.compare_cancel = "Cancel compare",
});
pub const status_hints: []const Action = &.{
@ -201,6 +220,37 @@ pub const tab = struct {
.expand_collapse => _ = toggleTierAtCursor(state, app),
.metric_next => cycleMetric(state),
.resolution_next => cycleResolution(state),
.compare_select => {
// Disabled while a compare view is up Esc to return first.
if (state.compare_view != null) return;
if (state.table_row_count == 0) return;
toggleSelection(state, app, state.cursor);
},
.compare_commit => {
// If a compare view is up, treat `c` as toggle-off
// (mirrors Esc semantics on this tab).
if (state.compare_view != null) {
clearCompareView(state, app);
clearSelections(state);
app.setStatus("");
return;
}
commitCompare(state, app);
},
.compare_cancel => {
// Esc semantics: drop active compare view if up,
// else clear pending selections, else no-op.
if (state.compare_view != null) {
clearCompareView(state, app);
clearSelections(state);
app.setStatus("");
return;
}
if (selectionCount(state) > 0) {
clearSelections(state);
app.setStatus("");
}
},
}
}
@ -447,26 +497,6 @@ fn setSelectionStatus(state: *const State, 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(state: *State, app: *App, idx: usize) void {
toggleSelection(state, app, idx);
}
/// Public entry for the `compare_commit` action.
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(state: *State, app: *App) void {
clearCompareView(state, app);
clearSelections(state);
app.setStatus("");
}
// Compare commit
/// Attempt to run compare. Called when the user presses `c`.
@ -663,63 +693,6 @@ fn aggregateFromSummary(
}
}
// Compare key handler (intercepted from tui.zig)
/// Intercepted before `matchAction` runs when the active tab is
/// history. Returns true if the key was consumed.
///
/// 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 (state.compare_view != null) {
clearCompareView(state, app);
clearSelections(state);
app.setStatus("");
ctx.consumeAndRedraw();
return true;
}
if (selectionCount(state) > 0) {
clearSelections(state);
app.setStatus("");
ctx.consumeAndRedraw();
return true;
}
return false;
}
// '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 (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 (state.compare_view != null) {
clearCompareView(state, app);
clearSelections(state);
app.setStatus("");
ctx.consumeAndRedraw();
return true;
}
commitCompare(state, app);
ctx.consumeAndRedraw();
return true;
}
return false;
}
// Table row model
/// One row in the rendered recent-snapshots table.

View file

@ -24,11 +24,9 @@ pub const Action = enum {
select_next,
select_prev,
expand_collapse,
select_symbol,
symbol_input,
help,
reload_portfolio,
collapse_all_calls,
collapse_all_puts,
options_filter_1,
options_filter_2,
@ -39,17 +37,6 @@ pub const Action = enum {
options_filter_7,
options_filter_8,
options_filter_9,
history_metric_next,
history_resolution_next,
/// History tab: toggle inclusion of the row under the cursor in the
/// compare-selection set (0, 1, or 2 rows). Defaults: 's' and space.
compare_select,
/// History tab: run compare if exactly 2 rows are selected; otherwise
/// status-bar hint. Default: 'c'.
compare_commit,
/// History tab: clear the current compare view (return to timeline)
/// or clear pending selections. Default: escape.
compare_cancel,
sort_col_next,
sort_col_prev,
sort_reverse,
@ -179,12 +166,9 @@ pub const global_default_bindings = [_]Binding{
.{ .action = .select_prev, .key = .{ .codepoint = 'k' } },
.{ .action = .select_prev, .key = .{ .codepoint = vaxis.Key.up } },
.{ .action = .expand_collapse, .key = .{ .codepoint = vaxis.Key.enter } },
.{ .action = .select_symbol, .key = .{ .codepoint = 's' } },
.{ .action = .select_symbol, .key = .{ .codepoint = vaxis.Key.space } },
.{ .action = .symbol_input, .key = .{ .codepoint = '/' } },
.{ .action = .help, .key = .{ .codepoint = '?' } },
.{ .action = .reload_portfolio, .key = .{ .codepoint = 'R' } },
.{ .action = .collapse_all_calls, .key = .{ .codepoint = 'c' } },
.{ .action = .collapse_all_puts, .key = .{ .codepoint = 'p' } },
.{ .action = .options_filter_1, .key = .{ .codepoint = '1', .mods = .{ .ctrl = true } } },
.{ .action = .options_filter_2, .key = .{ .codepoint = '2', .mods = .{ .ctrl = true } } },
@ -195,17 +179,6 @@ pub const global_default_bindings = [_]Binding{
.{ .action = .options_filter_7, .key = .{ .codepoint = '7', .mods = .{ .ctrl = true } } },
.{ .action = .options_filter_8, .key = .{ .codepoint = '8', .mods = .{ .ctrl = true } } },
.{ .action = .options_filter_9, .key = .{ .codepoint = '9', .mods = .{ .ctrl = true } } },
.{ .action = .history_metric_next, .key = .{ .codepoint = 'm' } },
.{ .action = .history_resolution_next, .key = .{ .codepoint = 't' } },
// Compare bindings live AFTER the existing `s`/`c`/space bindings
// so `matchAction` (first-match-wins) returns the original action
// in non-history tabs. The history-tab intercept in `tui.zig`
// routes these keys directly to `handleCompareKey` before
// `matchAction` is consulted.
.{ .action = .compare_select, .key = .{ .codepoint = 's' } },
.{ .action = .compare_select, .key = .{ .codepoint = vaxis.Key.space } },
.{ .action = .compare_commit, .key = .{ .codepoint = 'c' } },
.{ .action = .compare_cancel, .key = .{ .codepoint = vaxis.Key.escape } },
.{ .action = .sort_col_next, .key = .{ .codepoint = '>' } },
.{ .action = .sort_col_prev, .key = .{ .codepoint = '<' } },
.{ .action = .sort_reverse, .key = .{ .codepoint = 'o' } },