diff --git a/src/tui.zig b/src/tui.zig index 14c146c..a2c1716 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -325,6 +325,15 @@ comptime { } } +// TODO(scoped-keybinds): comptime validator that tabs don't bind +// keys already bound in the global keymap. Skipped in commit 1 +// because tabs (portfolio/options/history) currently declare Enter +// as a tab-local binding for `expand_collapse` while Enter remains +// globally bound. The validator gets added at the END of the +// migration (after commit 8 removes the global Enter binding) so +// it doesn't block intermediate commits. Runtime check on user +// keys.srf still rejects conflicts at TUI startup. + /// Per-symbol fetched data. Owned by `App` and accessed as /// `app.symbol_data.*`. Populated by whichever tab fetches first /// (typically the perf or quote tab); consumed by every tab that @@ -799,6 +808,37 @@ pub const App = struct { return false; } + /// Tab-local keybind dispatch. Walks the active tab's + /// `default_bindings` looking for a key match. On match, + /// invokes `tab.handleAction(state, app, action)` and returns + /// `true`. Returns `false` if no binding matched. + /// + /// Called as a fallback AFTER the global keymap; under the + /// "globals always win" rule, tabs are forbidden (by validator + /// at comptime, by user-config check at runtime) from binding + /// keys that are already global, so a key reaching here is by + /// definition not a global keybind. + /// + /// Adding a tab-local action: declare it in the tab's `Action` + /// enum, bind it in `default_bindings`, and `handleAction` runs + /// it. No edit to `tui.zig` required. + fn dispatchTabLocalKey(self: *App, key: vaxis.Key) bool { + inline for (std.meta.fields(@TypeOf(tab_modules))) |field| { + if (std.mem.eql(u8, field.name, @tagName(self.active_tab))) { + const Module = @field(tab_modules, field.name); + for (Module.tab.default_bindings) |binding| { + if (key.matches(binding.key.codepoint, binding.key.mods)) { + const state_ptr = &@field(self.states, field.name); + Module.tab.handleAction(state_ptr, self, binding.action); + return true; + } + } + return false; + } + } + return false; + } + /// Outcome of a single keypress in an input-mode buffer (symbol /// input, date input, etc.). Returned by `handleInputBuffer` so /// the per-mode caller only needs to wire up the `committed` @@ -1207,7 +1247,15 @@ pub const App = struct { return; } - const action = self.keymap.matchAction(key) orelse return; + const action = self.keymap.matchAction(key) orelse { + // No global binding matched. Fall back to tab-local + // dispatch — the active tab may bind this key in its + // `default_bindings`. Globals win (no overlap allowed, + // enforced by validator + user-config check), so + // reaching here means the key is purely tab-local. + if (self.dispatchTabLocalKey(key)) return ctx.consumeAndRedraw(); + return; + }; switch (action) { .quit => { ctx.quit = true; diff --git a/src/tui/keybinds.zig b/src/tui/keybinds.zig index bebdfbe..9dbccda 100644 --- a/src/tui/keybinds.zig +++ b/src/tui/keybinds.zig @@ -101,7 +101,7 @@ pub const KeyMap = struct { // ── Defaults ───────────────────────────────────────────────── -const default_bindings = [_]Binding{ +pub const global_default_bindings = [_]Binding{ .{ .action = .quit, .key = .{ .codepoint = 'q' } }, .{ .action = .quit, .key = .{ .codepoint = 'c', .mods = .{ .ctrl = true } } }, .{ .action = .refresh, .key = .{ .codepoint = 'r' } }, @@ -125,7 +125,9 @@ const default_bindings = [_]Binding{ .{ .action = .scroll_top, .key = .{ .codepoint = 'g' } }, .{ .action = .scroll_bottom, .key = .{ .codepoint = 'G' } }, .{ .action = .page_down, .key = .{ .codepoint = vaxis.Key.page_down } }, + .{ .action = .page_down, .key = .{ .codepoint = 'f', .mods = .{ .ctrl = true } } }, .{ .action = .page_up, .key = .{ .codepoint = vaxis.Key.page_up } }, + .{ .action = .page_up, .key = .{ .codepoint = 'b', .mods = .{ .ctrl = true } } }, .{ .action = .select_next, .key = .{ .codepoint = 'j' } }, .{ .action = .select_next, .key = .{ .codepoint = vaxis.Key.down } }, .{ .action = .select_prev, .key = .{ .codepoint = 'k' } }, @@ -173,7 +175,7 @@ const default_bindings = [_]Binding{ }; pub fn defaults() KeyMap { - return .{ .bindings = &default_bindings }; + return .{ .bindings = &global_default_bindings }; } // ── SRF serialization ──────────────────────────────────────── @@ -323,7 +325,7 @@ pub fn printDefaults(io: std.Io) !void { try out.writeAll("# F1-F12, insert, delete\n"); try out.writeAll("# Multiple lines with the same action = multiple bindings.\n"); - for (default_bindings) |b| { + for (global_default_bindings) |b| { var key_buf: [32]u8 = undefined; const key_str = formatKeyCombo(b.key, &key_buf) orelse continue; try out.print("action::{s},key::{s}\n", .{ @tagName(b.action), key_str });